diff --git a/core/assets/css/_components.css b/core/assets/css/_components.css deleted file mode 100644 index 1cd9eb486..000000000 --- a/core/assets/css/_components.css +++ /dev/null @@ -1,3 +0,0 @@ -/* -This file was generated by the Surface compiler. -*/ diff --git a/core/assets/css/app.css b/core/assets/css/app.css index e78a1673e..7b6c25547 100644 --- a/core/assets/css/app.css +++ b/core/assets/css/app.css @@ -2,6 +2,274 @@ @tailwind components; +trix-toolbar { + @apply mb-4 drop-shadow-lg h-8; +} + +trix-toolbar .trix-button-row { + height: 100%; + display: flex; + flex-wrap: nowrap; + justify-content: space-between; + overflow-x: auto; +} + +trix-toolbar .trix-button-group { + @apply flex rounded-lg overflow-hidden h-full; + } + trix-toolbar .trix-button-group:not(:first-child) { + margin-left: 1.5vw; } + @media (max-width: 768px) { + trix-toolbar .trix-button-group:not(:first-child) { + margin-left: 0; } } + +trix-toolbar .trix-button-group-spacer { + flex-grow: 1; } + @media (max-width: 768px) { + trix-toolbar .trix-button-group-spacer { + display: none; } } + +trix-toolbar .trix-button { + height: 100%; + padding: 4px 4px 4px 4px; + position: relative; + float: left; + font-size: 0.75em; + font-weight: 600; + white-space: nowrap; + outline: none; + border: none; + padding: 4px; + border-radius: 0; + background: #fff; } + trix-toolbar .trix-button:not(:first-child) { + border-left: 1px solid #eee; } + trix-toolbar .trix-button.trix-active { + @apply bg-primarylight; + } + trix-toolbar .trix-button:not(:disabled) { + cursor: pointer; } + trix-toolbar .trix-button:disabled { + color: rgba(0, 0, 0, 0.3); } + @media (max-width: 768px) { + trix-toolbar .trix-button { + letter-spacing: -0.01em; + padding: 0 0.3em; } } + +trix-toolbar .trix-button--icon { + font-size: inherit; + width: 2.6em; + max-width: calc(0.8em + 4vw); + text-indent: -9999px; } + @media (max-width: 768px) { + trix-toolbar .trix-button--icon { + height: 100%; + max-width: calc(0.8em + 3.5vw); } } + trix-toolbar .trix-button--icon::before { + display: inline-block; + position: absolute; + top: 4px; + right: 4px; + bottom: 4px; + left: 4px; + content: ""; + background-position: center; + background-repeat: no-repeat; + background-size: contain; } + @media (max-width: 768px) { + trix-toolbar .trix-button--icon::before { + right: 6%; + left: 6%; } } + trix-toolbar .trix-button--icon:disabled::before { + opacity: 0.3; } + +.trix-button-group--file-tools { + border: 0 !important; +} + +.trix-button--icon-attach, +.trix-button--icon-decrease-nesting-level, +.trix-button--icon-increase-nesting-level { + display :none; +} + +trix-toolbar .trix-button--icon-attach::before { + @apply bg-wysiwyg-attach +} + +trix-toolbar .trix-button--icon-bold::before { + @apply bg-wysiwyg-bold +} + +trix-toolbar .trix-button--icon-italic::before { + @apply bg-wysiwyg-italic +} + +trix-toolbar .trix-button--icon-link::before { + @apply bg-wysiwyg-link +} + +trix-toolbar .trix-button--icon-strike::before { + @apply bg-wysiwyg-strike +} + +trix-toolbar .trix-button--icon-quote::before { + @apply bg-wysiwyg-quote +} + +trix-toolbar .trix-button--icon-heading-1::before { + @apply bg-wysiwyg-heading +} + +trix-toolbar .trix-button--icon-code::before { + @apply bg-wysiwyg-code +} + +trix-toolbar .trix-button--icon-bullet-list::before { + @apply bg-wysiwyg-list-bullet +} + +trix-toolbar .trix-button--icon-number-list::before { + @apply bg-wysiwyg-list-number +} + +trix-toolbar .trix-button--icon-undo::before { + @apply bg-wysiwyg-history-undo +} + +trix-toolbar .trix-button--icon-redo::before { + @apply bg-wysiwyg-history-redo +} + +trix-toolbar .trix-button--icon-decrease-nesting-level::before { + @apply bg-wysiwyg-nesting-level-decrease +} + +trix-toolbar .trix-button--icon-increase-nesting-level::before { + @apply bg-wysiwyg-nesting-level-increase +} + +trix-toolbar .trix-dialogs { + @apply relative; +} + +trix-toolbar .trix-dialog { + @apply absolute left-0 right-0 top-4 bg-white rounded-lg drop-shadow-lg p-4 z-10; +} + +trix-toolbar .trix-input--dialog { + @apply h-12 rounded bg-white outline-none pl-4 font-body text-bodymedium placeholder-grey2 text-grey1 border-2 border-grey3 focus:border-primary; +} +trix-toolbar .trix-input--dialog.validate:invalid { + @apply border-warning bg-white +} + +trix-toolbar [data-trix-dialog] [data-trix-validate]:invalid { + @apply border-warning bg-white +} + +trix-toolbar .trix-button--dialog { + @apply text-button font-button text-primary; +} + +trix-toolbar .trix-dialog__link-fields .trix-button-group { + @apply pl-4; +} + +trix-toolbar .trix-dialog__link-fields .trix-button { + border-left: 0px; +} + +trix-toolbar .trix-dialog--link { + max-width: 600px; } + +trix-toolbar .trix-dialog__link-fields { + display: flex; + align-items: baseline; } + trix-toolbar .trix-dialog__link-fields .trix-input { + flex: 1; } + trix-toolbar .trix-dialog__link-fields .trix-button-group { + flex: 0 0 content; + margin: 0; } + + +trix-editor, .wysiwig { + @apply text-bodylarge font-body text-grey1 w-full outline-none; +} + +trix-editor h1, .wysiwig h1 { + @apply text-title2 font-title2 mb-8; +} + +trix-editor strong, .wysiwig strong { + @apply font-bold; +} + +trix-editor h1 strong, .wysiwig h1 strong { + @apply font-title1; +} + +trix-editor strong, .wysiwig strong { + @apply font-bold; +} + +trix-editor a:not(.no-underline), .wysiwig a:not(.no-underline) { + @apply text-primary underline cursor-pointer; +} + +trix-editor a:visited, .wysiwig a:visited { + @apply text-primary; +} + +trix-editor ul, .wysiwig ul { + @apply mb-8 list-none; +} + +trix-editor ul li::before, .wysiwig ul li::before { + @apply bg-wysiwyg-bullet text-primary; + vertical-align: center; + content: "\2022"; /* Add content: \2022 is the CSS Code/unicode for a bullet */ + display: inline-block; /* Needed to add space between the bullet and the text */ + width: 30px; /* Also needed for space (tweak if needed) */ + margin-left: 6px; /* Also needed for space (tweak if needed) */ + background-size: 10px 10px; + background-repeat: no-repeat; + background-position: left center; +} + +trix-editor ol, .wysiwig ol { + @apply mb-8 list-none; + counter-reset: li; +} + +trix-editor ol li::before, .wysiwig ol li::before { + @apply text-primary font-label text-title5; + content: counter(li) "."; color: theme('colors.primary'); + display: inline-block; + width: 30px; /* Also needed for space (tweak if needed) */ + margin-left: 6px; /* Also needed for space (tweak if needed) */ +} + +trix-editor ol li, .wysiwig ol li { + counter-increment: li +} + +trix-editor pre, .wysiwig pre { + @apply text-mono font-mono text-grey2 mb-8 p-8 bg-grey5 rounded-lg relative w-full whitespace-pre-wrap; + vertical-align: top; +} + +trix-editor blockquote, .wysiwig blockquote { + @apply pl-11 pr-11 mb-8 relative font-quote text-quote; +} + +trix-editor blockquote::before, .wysiwig blockquote::before { + @apply text-title0 font-title0 text-primary absolute -left-2px top-6px; + vertical-align: center; + content: "”"; + width: 0px; +} + @tailwind utilities; @layer utilities { diff --git a/core/assets/js/app.js b/core/assets/js/app.js index 7f85dc212..b690b919b 100644 --- a/core/assets/js/app.js +++ b/core/assets/js/app.js @@ -31,6 +31,7 @@ import { LiveContent, LiveField } from "./live_content"; import { Tabbar, TabbarItem, TabbarFooterItem } from "./tabbar"; import { Clipboard } from "./clipboard"; import { FeldsparApp } from "./feldspar_app"; +import { Wysiwyg } from "./wysiwyg"; window.registerAPNSDeviceToken = registerAPNSDeviceToken; @@ -103,6 +104,7 @@ let Hooks = { TabbarFooterItem, NativeWrapper, FeldsparApp, + Wysiwyg }; let liveSocket = new LiveSocket("/live", Socket, { diff --git a/core/assets/js/wysiwyg.js b/core/assets/js/wysiwyg.js new file mode 100644 index 000000000..e300c2e30 --- /dev/null +++ b/core/assets/js/wysiwyg.js @@ -0,0 +1,10 @@ +import Trix from "trix"; + +export const Wysiwyg = { + mounted() { + const element = document.querySelector("trix-editor"); + element.editor.element.addEventListener("trix-change", (e) => { + element.dispatchEvent(new Event("input", {bubbles: true})) + }); + }, +}; \ No newline at end of file diff --git a/core/assets/package-lock.json b/core/assets/package-lock.json index 945b7a4b9..b25833975 100644 --- a/core/assets/package-lock.json +++ b/core/assets/package-lock.json @@ -17,6 +17,7 @@ "phoenix_live_view": "file:../deps/phoenix_live_view", "stringify": "^5.2.0", "topbar": "^0.1.4", + "trix": "^2.0.7", "workbox-precaching": "^6.1.5", "workbox-routing": "^6.1.5", "workbox-strategies": "^6.1.5" @@ -8169,8 +8170,9 @@ }, "node_modules/postcss-import": { "version": "13.0.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-13.0.0.tgz", + "integrity": "sha512-LPUbm3ytpYopwQQjqgUH4S3EM/Gb9QsaSPP/5vnoi+oKVy3/mIk2sc0Paqw7RL57GpScm9MdIMUypw2znWiBpg==", "dev": true, - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -12330,6 +12332,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/trix": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/trix/-/trix-2.0.7.tgz", + "integrity": "sha512-qnqElm1BA4XYMgwowEHlF4xE4wfEjFBpmybvdzVUQP4OTzQxRXVEQNP4WSvWT6HzV4wYFP06/HSo14fWoGo6jQ==" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -18941,6 +18948,8 @@ }, "postcss-import": { "version": "13.0.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-13.0.0.tgz", + "integrity": "sha512-LPUbm3ytpYopwQQjqgUH4S3EM/Gb9QsaSPP/5vnoi+oKVy3/mIk2sc0Paqw7RL57GpScm9MdIMUypw2znWiBpg==", "dev": true, "requires": { "postcss-value-parser": "^4.0.0", @@ -21740,6 +21749,11 @@ "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", "dev": true }, + "trix": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/trix/-/trix-2.0.7.tgz", + "integrity": "sha512-qnqElm1BA4XYMgwowEHlF4xE4wfEjFBpmybvdzVUQP4OTzQxRXVEQNP4WSvWT6HzV4wYFP06/HSo14fWoGo6jQ==" + }, "ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/core/assets/package.json b/core/assets/package.json index 094b39c6f..8abb21edf 100644 --- a/core/assets/package.json +++ b/core/assets/package.json @@ -19,6 +19,7 @@ "phoenix_live_view": "file:../deps/phoenix_live_view", "stringify": "^5.2.0", "topbar": "^0.1.4", + "trix": "^2.0.7", "workbox-precaching": "^6.1.5", "workbox-routing": "^6.1.5", "workbox-strategies": "^6.1.5" diff --git a/core/assets/postcss.config.js b/core/assets/postcss.config.js index f902aaa2f..a28814258 100644 --- a/core/assets/postcss.config.js +++ b/core/assets/postcss.config.js @@ -1,6 +1,6 @@ module.exports = { - plugins: [ - require("tailwindcss"), - require("autoprefixer"), - ], + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, }; diff --git a/core/assets/static/images/wysiwyg/attach.svg b/core/assets/static/images/wysiwyg/attach.svg new file mode 100644 index 000000000..f2d182d9a --- /dev/null +++ b/core/assets/static/images/wysiwyg/attach.svg @@ -0,0 +1,3 @@ + + + diff --git a/core/assets/static/images/wysiwyg/bold.svg b/core/assets/static/images/wysiwyg/bold.svg new file mode 100644 index 000000000..9fa75d7e4 --- /dev/null +++ b/core/assets/static/images/wysiwyg/bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/core/assets/static/images/wysiwyg/bullet.svg b/core/assets/static/images/wysiwyg/bullet.svg new file mode 100644 index 000000000..26277df56 --- /dev/null +++ b/core/assets/static/images/wysiwyg/bullet.svg @@ -0,0 +1,7 @@ + + + bullit + + + + diff --git a/core/assets/static/images/wysiwyg/code.svg b/core/assets/static/images/wysiwyg/code.svg new file mode 100644 index 000000000..72eeaa6d7 --- /dev/null +++ b/core/assets/static/images/wysiwyg/code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/assets/static/images/wysiwyg/heading.svg b/core/assets/static/images/wysiwyg/heading.svg new file mode 100644 index 000000000..a46f0288c --- /dev/null +++ b/core/assets/static/images/wysiwyg/heading.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/core/assets/static/images/wysiwyg/history_redo.svg b/core/assets/static/images/wysiwyg/history_redo.svg new file mode 100644 index 000000000..ea5a9c843 --- /dev/null +++ b/core/assets/static/images/wysiwyg/history_redo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/assets/static/images/wysiwyg/history_undo.svg b/core/assets/static/images/wysiwyg/history_undo.svg new file mode 100644 index 000000000..77c64cffc --- /dev/null +++ b/core/assets/static/images/wysiwyg/history_undo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/assets/static/images/wysiwyg/italic.svg b/core/assets/static/images/wysiwyg/italic.svg new file mode 100644 index 000000000..d78b865ea --- /dev/null +++ b/core/assets/static/images/wysiwyg/italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/core/assets/static/images/wysiwyg/link.svg b/core/assets/static/images/wysiwyg/link.svg new file mode 100644 index 000000000..f6370f229 --- /dev/null +++ b/core/assets/static/images/wysiwyg/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/assets/static/images/wysiwyg/list_bullet.svg b/core/assets/static/images/wysiwyg/list_bullet.svg new file mode 100644 index 000000000..c317a6914 --- /dev/null +++ b/core/assets/static/images/wysiwyg/list_bullet.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/core/assets/static/images/wysiwyg/list_number.svg b/core/assets/static/images/wysiwyg/list_number.svg new file mode 100644 index 000000000..2e0ddcddb --- /dev/null +++ b/core/assets/static/images/wysiwyg/list_number.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/core/assets/static/images/wysiwyg/nesting_level_decrease.svg b/core/assets/static/images/wysiwyg/nesting_level_decrease.svg new file mode 100644 index 000000000..9f177503a --- /dev/null +++ b/core/assets/static/images/wysiwyg/nesting_level_decrease.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/core/assets/static/images/wysiwyg/nesting_level_increase.svg b/core/assets/static/images/wysiwyg/nesting_level_increase.svg new file mode 100644 index 000000000..4dc841031 --- /dev/null +++ b/core/assets/static/images/wysiwyg/nesting_level_increase.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/core/assets/static/images/wysiwyg/quote.svg b/core/assets/static/images/wysiwyg/quote.svg new file mode 100644 index 000000000..ccff91061 --- /dev/null +++ b/core/assets/static/images/wysiwyg/quote.svg @@ -0,0 +1,3 @@ + + + diff --git a/core/assets/static/images/wysiwyg/strike.svg b/core/assets/static/images/wysiwyg/strike.svg new file mode 100644 index 000000000..b58f4e9fb --- /dev/null +++ b/core/assets/static/images/wysiwyg/strike.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/assets/tailwind.config.js b/core/assets/tailwind.config.js index 4217de3a0..2b493d70f 100644 --- a/core/assets/tailwind.config.js +++ b/core/assets/tailwind.config.js @@ -7,7 +7,15 @@ module.exports = { "../**/*.ex", "./js/**/*.js", ], - + safelist: [ + 'drop-shadow-2xl', + 'text-bold', + 'text-pre', + 'font-pre', + {pattern: /bg-wysiwyg-./ }, + {pattern: /h-wysiwyg-./ }, + {pattern: /border-./ }, + ], theme: { boxShadow: { top4px: "inset 0 4px 0 0 rgba(0, 0, 0, 0.15)", @@ -48,9 +56,24 @@ module.exports = { }, backgroundImage: { "square-border-striped": "url('/images/square_border_striped.png')", + "wysiwyg-bold": "url('/images/wysiwyg/bold.svg')", + "wysiwyg-italic": "url('/images/wysiwyg/italic.svg')", + "wysiwyg-strike": "url('/images/wysiwyg/strike.svg')", + "wysiwyg-link": "url('/images/wysiwyg/link.svg')", + "wysiwyg-heading": "url('/images/wysiwyg/heading.svg')", + "wysiwyg-quote": "url('/images/wysiwyg/quote.svg')", + "wysiwyg-code": "url('/images/wysiwyg/code.svg')", + "wysiwyg-list-bullet": "url('/images/wysiwyg/list_bullet.svg')", + "wysiwyg-list-number": "url('/images/wysiwyg/list_number.svg')", + "wysiwyg-nesting-level-decrease": "url('/images/wysiwyg/nesting_level_decrease.svg')", + "wysiwyg-nesting-level-increase": "url('/images/wysiwyg/nesting_level_increase.svg')", + "wysiwyg-attach": "url('/images/wysiwyg/attach.svg')", + "wysiwyg-history-undo": "url('/images/wysiwyg/history_undo.svg')", + "wysiwyg-history-redo": "url('/images/wysiwyg/history_redo.svg')", + "wysiwyg-bullet": "url('/images/wysiwyg/bullet.svg')", }, spacing: { - aap: "1px", + "1px": "1px", "2px": "2px", "3px": "3px", "5px": "5px", @@ -145,6 +168,9 @@ module.exports = { "button-sm": "14px", "file-selector": "96px", }, + borderWidth: { + "1px": "px", + }, fontFamily: { title0: ["Finador-Black", "sans-serif"], title1: ["Finador-Black", "sans-serif"], @@ -165,8 +191,10 @@ module.exports = { hint: ["Finador-LightOblique", "sans-serif"], tablehead: ["Finador-Bold", "sans-serif"], tablerow: ["Finador-Regular", "sans-serif"], + bold: ["Finador-Bold", "sans-serif"], + quote: ["Finador-Bold", "sans-serif"], }, - fontSize: { + fontSize: { title0: ["64px", "68px"], title1: ["50px", "55px"], title2: ["40px", "44px"], @@ -194,6 +222,8 @@ module.exports = { hint: ["20px", "24px"], tablehead: ["14px", "16px"], tablerow: ["14px", "16px"], + mono: ["20px", "24px"], + quote: ["24px", "30px"], }, minWidth: { "1/2": "50%", @@ -203,18 +233,22 @@ module.exports = { card: "376px", form: "400px", sheet: "760px", - popup: "480px", + popup: "480px", "popup-sm": "520px", "popup-md": "730px", "popup-lg": "1228px", "3/4": "75%", "9/10": "90%", }, + minHeight: { + "wysiwyg-editor": "512px", + }, maxHeight: { dropdown: "317px", header1: "376px", form: "400px", mailto: "128px", + "wysiwyg-editor": "960px", }, }, }, diff --git a/core/frameworks/pixel/components/button.ex b/core/frameworks/pixel/components/button.ex index ea1f4e81d..716dfcf43 100644 --- a/core/frameworks/pixel/components/button.ex +++ b/core/frameworks/pixel/components/button.ex @@ -9,12 +9,19 @@ defmodule Frameworks.Pixel.Button do attr(:action, :map, required: true) attr(:face, :map, required: true) + attr(:enabled?, :boolean, default: true) def dynamic(assigns) do ~H""" - <.action {@action}> - <.face {@face} /> - + <%= if @enabled? do %> + <.action {@action}> + <.face {@face} /> + + <% else %> +
+ <.face {@face} /> +
+ <% end %> """ end diff --git a/core/frameworks/pixel/components/form.ex b/core/frameworks/pixel/components/form.ex index 72b6346dc..baff65ba2 100644 --- a/core/frameworks/pixel/components/form.ex +++ b/core/frameworks/pixel/components/form.ex @@ -326,7 +326,7 @@ defmodule Frameworks.Pixel.Form do attr(:form, :any, required: true) attr(:field, :atom, required: true) - attr(:label_text, :string) + attr(:label_text, :string, default: nil) attr(:label_color, :string, default: "text-grey1") attr(:background, :atom, default: :light) attr(:debounce, :string, default: "1000") @@ -379,6 +379,79 @@ defmodule Frameworks.Pixel.Form do """ end + attr(:form, :any, required: true) + attr(:field, :atom, required: true) + attr(:label_text, :string, default: nil) + attr(:label_color, :string, default: "text-grey1") + attr(:background, :atom, default: :light) + attr(:debounce, :string, default: "1000") + attr(:min_height, :string, default: "min-h-wysiwyg-editor") + attr(:max_height, :string, default: "max-h-wysiwyg-editor") + + def wysiwyg_area(%{form: form, field: field} = assigns) do + errors = form[field].errors + has_errors = Enum.count(errors) > 0 + field_id = String.to_atom(input_id(form, field)) + + input_static_class = + "#{field_tag(@input)} field-input text-grey1 text-bodymedium font-body p-4 w-full border-2 focus:outline-none rounded" + + input_dynamic_class = "border-grey3" + active_color = active_input_color(assigns) + + assigns = + assign(assigns, %{ + field_id: field_id, + field_name: input_name(form, field), + field_value: html_escape(input_value(form, field) || ""), + target: target(form), + input_static_class: input_static_class, + input_dynamic_class: input_dynamic_class, + active_color: active_color, + errors: errors, + has_errors: has_errors + }) + + ~H""" + <.field + field={@field_id} + label_text={@label_text} + label_color={@label_color} + background={@background} + errors={@errors} + extra_space={false} + > + +
+ + <%= @field_value %> + +
+ + """ + end + attr(:static_path, :any, required: true) attr(:label_text, :string, default: nil) attr(:label_color, :string, default: "text-grey1") diff --git a/core/frameworks/signal/_public.ex b/core/frameworks/signal/_public.ex index 297db4049..b319fe5a4 100644 --- a/core/frameworks/signal/_public.ex +++ b/core/frameworks/signal/_public.ex @@ -11,6 +11,7 @@ defmodule Frameworks.Signal.Public do "Systems.Observatory.Switch", "Systems.Project.Switch", "Systems.Assignment.Switch", + "Systems.Consent.Switch", "Systems.Workflow.Switch", "Systems.Pool.Switch", "Systems.Student.Switch", diff --git a/core/lib/core/factories.ex b/core/lib/core/factories.ex index 6387e396d..0a7292e6c 100644 --- a/core/lib/core/factories.ex +++ b/core/lib/core/factories.ex @@ -32,7 +32,8 @@ defmodule Core.Factories do Bookkeeping, Content, Org, - Project + Project, + Consent } def valid_user_password, do: Faker.Util.format("%5d%5a%5A#") @@ -279,6 +280,18 @@ defmodule Core.Factories do build(:tool_ref, %{}) end + def build(:consent_agreement) do + build(:consent_agreement, %{}) + end + + def build(:consent_revision) do + build(:consent_revision, %{}) + end + + def build(:consent_signature) do + build(:consent_signature, %{}) + end + def build(:auth_node, %{} = attributes) do %Authorization.Node{} |> struct!(attributes) @@ -436,6 +449,35 @@ defmodule Core.Factories do |> struct!(attributes) end + def build(:consent_agreement, %{} = attributes) do + {auth_node, attributes} = Map.pop(attributes, :auth_node, build(:auth_node)) + + %Consent.AgreementModel{ + auth_node: auth_node + } + |> struct!(attributes) + end + + def build(:consent_revision, %{} = attributes) do + {agreement, attributes} = Map.pop(attributes, :agreement, build(:consent_agreement)) + + %Consent.RevisionModel{ + agreement: agreement + } + |> struct!(attributes) + end + + def build(:consent_signature, %{} = attributes) do + {user, attributes} = Map.pop(attributes, :user, build(:member)) + {revision, attributes} = Map.pop(attributes, :revision, build(:consent_revision)) + + %Consent.SignatureModel{ + user: user, + revision: revision + } + |> struct!(attributes) + end + def build(:assignment, %{} = attributes) do {auth_node, attributes} = Map.pop(attributes, :auth_node, build(:auth_node)) {budget, attributes} = Map.pop(attributes, :budget, build(:budget)) diff --git a/core/lib/core_web/live/user/forms/debug.ex b/core/lib/core_web/live/user/forms/debug.ex index 9fd19bc36..692c7ae2e 100644 --- a/core/lib/core_web/live/user/forms/debug.ex +++ b/core/lib/core_web/live/user/forms/debug.ex @@ -102,12 +102,6 @@ defmodule CoreWeb.User.Forms.Debug do |> save(changeset) end - # data(entity, :any) - # data(changeset, :any) - # data(role_labels, :list) - - attr(:user, :any, required: true) - @impl true def render(assigns) do ~H""" diff --git a/core/lib/core_web/live_form.ex b/core/lib/core_web/live_form.ex index d506eea1e..5250fff1c 100644 --- a/core/lib/core_web/live_form.ex +++ b/core/lib/core_web/live_form.ex @@ -15,7 +15,11 @@ defmodule CoreWeb.LiveForm do end def flash_persister_error(socket) do - message = dgettext("eyra-ui", "persister.error.flash") + socket + |> flash_persister_error(dgettext("eyra-ui", "persister.error.flash")) + end + + def flash_persister_error(socket, message) do Frameworks.Pixel.Flash.push_error(message) socket end @@ -62,7 +66,7 @@ defmodule CoreWeb.LiveForm do case Core.Persister.save(changeset.data, changeset) do {:ok, entity} -> socket - |> assign(entity: entity, changeset: changeset) + |> assign(entity: entity) |> flash_persister_saved() |> handle_auto_save_done() diff --git a/core/priv/gettext/en/LC_MESSAGES/eyra-alliance.po b/core/priv/gettext/en/LC_MESSAGES/eyra-alliance.po index 525e694f8..164468629 100644 --- a/core/priv/gettext/en/LC_MESSAGES/eyra-alliance.po +++ b/core/priv/gettext/en/LC_MESSAGES/eyra-alliance.po @@ -201,15 +201,3 @@ msgstr "The author of this study reviewed your contribution and wrote the follow #, elixir-autogen, elixir-format, ex-autogen, fuzzy msgid "participated.label" msgstr "Participated" - -#, elixir-autogen, elixir-format, ex-autogen -msgid "test.roundtrip.button" -msgstr "Test set-up" - -#, elixir-autogen, elixir-format, ex-autogen -msgid "test.roundtrip.text" -msgstr "Test your questionnaire set-up and check if you end up on the \"Round trip confirmed\" screen." - -#, elixir-autogen, elixir-format, ex-autogen -msgid "test.roundtrip.title" -msgstr "Is your questionnaire set up correctly?" diff --git a/core/priv/gettext/en/LC_MESSAGES/eyra-assignment.po b/core/priv/gettext/en/LC_MESSAGES/eyra-assignment.po index 229e9341d..b9543534c 100644 --- a/core/priv/gettext/en/LC_MESSAGES/eyra-assignment.po +++ b/core/priv/gettext/en/LC_MESSAGES/eyra-assignment.po @@ -58,34 +58,10 @@ msgstr "%{target} credits required" msgid "close.button" msgstr "Finish up" -#, elixir-autogen, elixir-format -msgid "config.nrofsubjects.label" -msgstr "How many participants do you need?" - #, elixir-autogen, elixir-format, fuzzy msgid "content.title" msgstr "Assignment" -#, elixir-autogen, elixir-format -msgid "devices.label" -msgstr "Which devices can participants use?" - -#, elixir-autogen, elixir-format -msgid "devices.title" -msgstr "Devices" - -#, elixir-autogen, elixir-format -msgid "duration.label" -msgstr "How man minutes do you estimate participants will need?" - -#, elixir-autogen, elixir-format -msgid "language.title" -msgstr "Language" - -#, elixir-autogen, elixir-format -msgid "languages.label" -msgstr "In what language is the assignment written?" - #, elixir-autogen, elixir-format msgid "open.button" msgstr "Open" @@ -101,3 +77,11 @@ msgstr "Publish" #, elixir-autogen, elixir-format, fuzzy msgid "retract.button" msgstr "Contact researcher" + +#, elixir-autogen, elixir-format +msgid "onboarding.consent.continue.button" +msgstr "Continue" + +#, elixir-autogen, elixir-format, fuzzy +msgid "onboarding.consent.title" +msgstr "Consent" diff --git a/core/priv/gettext/en/LC_MESSAGES/eyra-consent.po b/core/priv/gettext/en/LC_MESSAGES/eyra-consent.po new file mode 100644 index 000000000..95f6a6712 --- /dev/null +++ b/core/priv/gettext/en/LC_MESSAGES/eyra-consent.po @@ -0,0 +1,20 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#, elixir-autogen, elixir-format +msgid "consent-out-of-sync-error" +msgstr "Someone made changes to the consent text. Please refresh the page to continue." + +#, elixir-autogen, elixir-format +msgid "default.consent.text" +msgstr "
Write here your custom consent terms and conditions..
" diff --git a/core/priv/gettext/en/LC_MESSAGES/eyra-project.po b/core/priv/gettext/en/LC_MESSAGES/eyra-project.po index c90bf1fd1..33c4142d9 100644 --- a/core/priv/gettext/en/LC_MESSAGES/eyra-project.po +++ b/core/priv/gettext/en/LC_MESSAGES/eyra-project.po @@ -39,14 +39,6 @@ msgstr "Monitor" msgid "tabbar.item.monitor.forward" msgstr "Go to Monitor" -#, elixir-autogen, elixir-format -msgid "tabbar.item.privacy" -msgstr "Privacy" - -#, elixir-autogen, elixir-format -msgid "tabbar.item.privacy.forward" -msgstr "Go to Privacy" - #, elixir-autogen, elixir-format msgid "add.first.button" msgstr "Start your first project" @@ -182,3 +174,11 @@ msgstr "Support" #, elixir-autogen, elixir-format, fuzzy msgid "tabbar.item.support.forward" msgstr "Go to Support" + +#, elixir-autogen, elixir-format, fuzzy +msgid "tabbar.item.gdpr" +msgstr "Consent" + +#, elixir-autogen, elixir-format, fuzzy +msgid "tabbar.item.gdpr.forward" +msgstr "Go to Consent" diff --git a/core/priv/gettext/eyra-alliance.pot b/core/priv/gettext/eyra-alliance.pot index 721c0e5e9..c012b8c41 100644 --- a/core/priv/gettext/eyra-alliance.pot +++ b/core/priv/gettext/eyra-alliance.pot @@ -201,15 +201,3 @@ msgstr "" #, elixir-autogen, elixir-format, ex-autogen msgid "participated.label" msgstr "" - -#, elixir-autogen, elixir-format, ex-autogen -msgid "test.roundtrip.button" -msgstr "" - -#, elixir-autogen, elixir-format, ex-autogen -msgid "test.roundtrip.text" -msgstr "" - -#, elixir-autogen, elixir-format, ex-autogen -msgid "test.roundtrip.title" -msgstr "" diff --git a/core/priv/gettext/eyra-assignment.pot b/core/priv/gettext/eyra-assignment.pot index ad180794a..4e34ae8f7 100644 --- a/core/priv/gettext/eyra-assignment.pot +++ b/core/priv/gettext/eyra-assignment.pot @@ -58,46 +58,30 @@ msgstr "" msgid "close.button" msgstr "" -#, elixir-autogen, elixir-format -msgid "config.nrofsubjects.label" -msgstr "" - #, elixir-autogen, elixir-format msgid "content.title" msgstr "" #, elixir-autogen, elixir-format -msgid "devices.label" -msgstr "" - -#, elixir-autogen, elixir-format -msgid "devices.title" -msgstr "" - -#, elixir-autogen, elixir-format -msgid "duration.label" -msgstr "" - -#, elixir-autogen, elixir-format -msgid "language.title" +msgid "open.button" msgstr "" #, elixir-autogen, elixir-format -msgid "languages.label" +msgid "preview.button" msgstr "" #, elixir-autogen, elixir-format -msgid "open.button" +msgid "publish.button" msgstr "" #, elixir-autogen, elixir-format -msgid "preview.button" +msgid "retract.button" msgstr "" #, elixir-autogen, elixir-format -msgid "publish.button" +msgid "onboarding.consent.continue.button" msgstr "" #, elixir-autogen, elixir-format -msgid "retract.button" +msgid "onboarding.consent.title" msgstr "" diff --git a/core/priv/gettext/eyra-consent.pot b/core/priv/gettext/eyra-consent.pot new file mode 100644 index 000000000..191c27999 --- /dev/null +++ b/core/priv/gettext/eyra-consent.pot @@ -0,0 +1,20 @@ +## This file is a PO Template file. +## +## "msgid"s here are often extracted from source code. +## Add new messages manually only if they're dynamic +## messages that can't be statically extracted. +## +## Run "mix gettext.extract" to bring this file up to +## date. Leave "msgstr"s empty as changing them here has no +## effect: edit them in PO (.po) files instead. +# +msgid "" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "consent-out-of-sync-error" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "default.consent.text" +msgstr "" diff --git a/core/priv/gettext/eyra-project.pot b/core/priv/gettext/eyra-project.pot index 524fa319b..5e68a8ae9 100644 --- a/core/priv/gettext/eyra-project.pot +++ b/core/priv/gettext/eyra-project.pot @@ -39,14 +39,6 @@ msgstr "" msgid "tabbar.item.monitor.forward" msgstr "" -#, elixir-autogen, elixir-format -msgid "tabbar.item.privacy" -msgstr "" - -#, elixir-autogen, elixir-format -msgid "tabbar.item.privacy.forward" -msgstr "" - #, elixir-autogen, elixir-format msgid "add.first.button" msgstr "" @@ -182,3 +174,11 @@ msgstr "" #, elixir-autogen, elixir-format msgid "tabbar.item.support.forward" msgstr "" + +#, elixir-autogen, elixir-format +msgid "tabbar.item.gdpr" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "tabbar.item.gdpr.forward" +msgstr "" diff --git a/core/priv/gettext/nl/LC_MESSAGES/eyra-alliance.po b/core/priv/gettext/nl/LC_MESSAGES/eyra-alliance.po index 1e78b16c8..98528df05 100644 --- a/core/priv/gettext/nl/LC_MESSAGES/eyra-alliance.po +++ b/core/priv/gettext/nl/LC_MESSAGES/eyra-alliance.po @@ -201,15 +201,3 @@ msgstr "De auteur van deze studie heeft jouw bijdrage beoordeeld en wil je het v #, elixir-autogen, elixir-format, ex-autogen, fuzzy msgid "participated.label" msgstr "Deelgenomen" - -#, elixir-autogen, elixir-format, ex-autogen -msgid "test.roundtrip.button" -msgstr "Controleer" - -#, elixir-autogen, elixir-format, ex-autogen -msgid "test.roundtrip.text" -msgstr "Test of jouw vragenlijst goed opgezet is en controleer of je terugkomt op het scherm \"Round trip bevestigd\"." - -#, elixir-autogen, elixir-format, ex-autogen -msgid "test.roundtrip.title" -msgstr "Is je vragenlijst goed opgezet?" diff --git a/core/priv/gettext/nl/LC_MESSAGES/eyra-assignment.po b/core/priv/gettext/nl/LC_MESSAGES/eyra-assignment.po index 0fddc3e12..6e5e08577 100644 --- a/core/priv/gettext/nl/LC_MESSAGES/eyra-assignment.po +++ b/core/priv/gettext/nl/LC_MESSAGES/eyra-assignment.po @@ -58,34 +58,10 @@ msgstr "%{target} credits vereist" msgid "close.button" msgstr "Afronden" -#, elixir-autogen, elixir-format -msgid "config.nrofsubjects.label" -msgstr "Hoeveel deelnemers heb je nodig?" - #, elixir-autogen, elixir-format, fuzzy msgid "content.title" msgstr "Jouw Panl ID" -#, elixir-autogen, elixir-format -msgid "devices.label" -msgstr "Welke apparaten mogen deelnemers gebruiken?" - -#, elixir-autogen, elixir-format -msgid "devices.title" -msgstr "Apparaten" - -#, elixir-autogen, elixir-format -msgid "duration.label" -msgstr "Hoeveel minuten schat je in dat deelnemers nodig hebben?" - -#, elixir-autogen, elixir-format -msgid "language.title" -msgstr "Taal" - -#, elixir-autogen, elixir-format -msgid "languages.label" -msgstr "In welke taal is de opdracht geschreven?" - #, elixir-autogen, elixir-format msgid "open.button" msgstr "Open" @@ -101,3 +77,11 @@ msgstr "Publiceren" #, elixir-autogen, elixir-format, fuzzy msgid "retract.button" msgstr "Intrekken" + +#, elixir-autogen, elixir-format +msgid "onboarding.consent.continue.button" +msgstr "Continue" + +#, elixir-autogen, elixir-format, fuzzy +msgid "onboarding.consent.title" +msgstr "Consent" diff --git a/core/priv/gettext/nl/LC_MESSAGES/eyra-consent.po b/core/priv/gettext/nl/LC_MESSAGES/eyra-consent.po new file mode 100644 index 000000000..1c43d4e89 --- /dev/null +++ b/core/priv/gettext/nl/LC_MESSAGES/eyra-consent.po @@ -0,0 +1,20 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: nl\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#, elixir-autogen, elixir-format +msgid "consent-out-of-sync-error" +msgstr "Iemand heeft de consent tekst is aangepast. Ververs de pagina om verder te gaan." + +#, elixir-autogen, elixir-format +msgid "default.consent.text" +msgstr "
Beschrijf hier de consent voorwaarden
" diff --git a/core/priv/gettext/nl/LC_MESSAGES/eyra-project.po b/core/priv/gettext/nl/LC_MESSAGES/eyra-project.po index bfa7228f1..4f5360b74 100644 --- a/core/priv/gettext/nl/LC_MESSAGES/eyra-project.po +++ b/core/priv/gettext/nl/LC_MESSAGES/eyra-project.po @@ -39,14 +39,6 @@ msgstr "Monitor" msgid "tabbar.item.monitor.forward" msgstr "Ga naar Monitor" -#, elixir-autogen, elixir-format -msgid "tabbar.item.privacy" -msgstr "Privacy" - -#, elixir-autogen, elixir-format -msgid "tabbar.item.privacy.forward" -msgstr "Ga naar Privacy" - #, elixir-autogen, elixir-format msgid "add.first.button" msgstr "Start je eerste project" @@ -182,3 +174,11 @@ msgstr "Support" #, elixir-autogen, elixir-format, fuzzy msgid "tabbar.item.support.forward" msgstr "Ga naar Support" + +#, elixir-autogen, elixir-format, fuzzy +msgid "tabbar.item.gdpr" +msgstr "Consent" + +#, elixir-autogen, elixir-format, fuzzy +msgid "tabbar.item.gdpr.forward" +msgstr "Ga naar Consent" diff --git a/core/priv/repo/migrations/20231015122507_add_consent.exs b/core/priv/repo/migrations/20231015122507_add_consent.exs new file mode 100644 index 000000000..aba6d5837 --- /dev/null +++ b/core/priv/repo/migrations/20231015122507_add_consent.exs @@ -0,0 +1,41 @@ +defmodule Core.Repo.Migrations.AddConsent do + use Ecto.Migration + + def up do + create table(:consent_agreements) do + add(:auth_node_id, references(:authorization_nodes), null: false) + timestamps() + end + + create table(:consent_revisions) do + add(:agreement_id, references(:consent_agreements, on_delete: :nothing)) + add(:source, :text) + timestamps() + end + + create table(:consent_signatures) do + add(:revision_id, references(:consent_revisions, on_delete: :nothing)) + add(:user_id, references(:users, on_delete: :nothing)) + timestamps() + end + + create(unique_index(:consent_signatures, [:revision_id, :user_id])) + + alter table(:assignments) do + add(:consent_agreement_id, references(:consent_agreements), null: true) + end + end + + def down do + alter table(:assignments) do + remove(:consent_agreement_id) + end + + drop(index(:consent_signatures, [:revision_id, :user_id])) + + drop(table(:consent_signatures)) + drop(table(:consent_revisions)) + drop(table(:consent_agreements)) + end + +end diff --git a/core/systems/assignment/_assembly.ex b/core/systems/assignment/_assembly.ex index a481f38ff..fe1f6e4fa 100644 --- a/core/systems/assignment/_assembly.ex +++ b/core/systems/assignment/_assembly.ex @@ -5,6 +5,7 @@ defmodule Systems.Assignment.Assembly do alias Systems.{ Project, Assignment, + Consent, Crew, Alliance, Lab @@ -18,10 +19,9 @@ defmodule Systems.Assignment.Assembly do def prepare(template, director, budget \\ nil, auth_node \\ Authorization.prepare_node()) do crew_auth_node = Authorization.prepare_node(auth_node) crew = Crew.Public.prepare(crew_auth_node) - info = Assignment.Public.prepare_info(info_attrs(template, director)) - workflow = prepare_workflow(template, auth_node) + consent_agreement = prepare_consent_agreement(auth_node) Assignment.Public.prepare( %{special: template}, @@ -29,6 +29,7 @@ defmodule Systems.Assignment.Assembly do info, workflow, budget, + consent_agreement, auth_node ) end @@ -60,6 +61,11 @@ defmodule Systems.Assignment.Assembly do Project.Public.prepare_tool_ref(special, field_name, tool) end + defp prepare_consent_agreement(%Authorization.Node{} = auth_node) do + agreement_auth_node = Authorization.prepare_node(auth_node) + Consent.Public.prepare_agreement(agreement_auth_node) + end + defp info_attrs(:lab, director) do %{ director: director, diff --git a/core/systems/assignment/_public.ex b/core/systems/assignment/_public.ex index 9f239ca96..9980addc2 100644 --- a/core/systems/assignment/_public.ex +++ b/core/systems/assignment/_public.ex @@ -16,6 +16,7 @@ defmodule Systems.Assignment.Public do alias Systems.{ Project, Assignment, + Consent, Budget, Workflow, Crew @@ -63,6 +64,19 @@ defmodule Systems.Assignment.Public do |> Repo.one!() end + def get_by_consent_agreement(consent_agreement, preload \\ []) + + def get_by_consent_agreement(%Consent.AgreementModel{id: id}, preload), + do: get_by_consent_agreement(id, preload) + + def get_by_consent_agreement(consent_agreement_id, preload) do + from(assignment in Assignment.Model, + where: assignment.consent_agreement_id == ^consent_agreement_id, + preload: ^preload + ) + |> Repo.one() + end + def get_by_workflow(workflow, preload \\ []) def get_by_workflow(%Workflow.Model{id: id}, preload), do: get_by_workflow(id, preload) @@ -130,13 +144,14 @@ defmodule Systems.Assignment.Public do |> Repo.all() end - def prepare(%{} = attrs, crew, info, workflow, budget, auth_node) do + def prepare(%{} = attrs, crew, info, workflow, budget, consent_agreement, auth_node) do %Assignment.Model{} |> Assignment.Model.changeset(attrs) |> Ecto.Changeset.put_assoc(:info, info) |> Ecto.Changeset.put_assoc(:workflow, workflow) |> Ecto.Changeset.put_assoc(:crew, crew) |> Ecto.Changeset.put_assoc(:budget, budget) + |> Ecto.Changeset.put_assoc(:consent_agreement, consent_agreement) |> Ecto.Changeset.put_assoc(:auth_node, auth_node) end diff --git a/core/systems/assignment/_switch.ex b/core/systems/assignment/_switch.ex index 6400c1123..1fd20ee67 100644 --- a/core/systems/assignment/_switch.ex +++ b/core/systems/assignment/_switch.ex @@ -41,6 +41,22 @@ defmodule Systems.Assignment.Switch do handle(signal, message) end + def intercept( + {:consent_agreement, _} = signal, + %{consent_agreement: consent_agreement} = message + ) do + if assignment = + Assignment.Public.get_by_consent_agreement( + consent_agreement, + Assignment.Model.preload_graph(:down) + ) do + handle( + {:assignment, signal}, + Map.merge(message, %{assignment: assignment}) + ) + end + end + def intercept({:crew_task, _} = signal, %{crew_task: %{crew_id: crew_id}} = message) do Assignment.Public.list_by_crew(crew_id, Assignment.Model.preload_graph(:down)) |> Enum.each( diff --git a/core/systems/assignment/content_page_builder.ex b/core/systems/assignment/content_page_builder.ex index 9327551dc..bea04e624 100644 --- a/core/systems/assignment/content_page_builder.ex +++ b/core/systems/assignment/content_page_builder.ex @@ -7,7 +7,6 @@ defmodule Systems.Assignment.ContentPageBuilder do Assignment, Project, Workflow, - Privacy, Support } @@ -155,7 +154,7 @@ defmodule Systems.Assignment.ContentPageBuilder do end defp get_tab_keys() do - [:config, :privacy, :items, :support, :invite, :monitor] + [:config, :gdpr, :items, :invite] end defp create_tab( @@ -245,23 +244,23 @@ defmodule Systems.Assignment.ContentPageBuilder do end defp create_tab( - :privacy, - _assignment, + :gdpr, + %{consent_agreement: consent_agreement}, show_errors, _assigns ) do ready? = false %{ - id: :privacy_form, + id: :gdpr_form, ready: ready?, show_errors: show_errors, - title: dgettext("eyra-project", "tabbar.item.privacy"), - forward_title: dgettext("eyra-project", "tabbar.item.privacy.forward"), + title: dgettext("eyra-project", "tabbar.item.gdpr"), + forward_title: dgettext("eyra-project", "tabbar.item.gdpr.forward"), type: :fullpage, - live_component: Privacy.Form, + live_component: Assignment.GdprForm, props: %{ - entity: %{} + entity: consent_agreement } } end diff --git a/core/systems/assignment/crew_page.ex b/core/systems/assignment/crew_page.ex index 30d99f099..5ee2d8291 100644 --- a/core/systems/assignment/crew_page.ex +++ b/core/systems/assignment/crew_page.ex @@ -35,6 +35,7 @@ defmodule Systems.Assignment.CrewPage do tool_ref_view: nil ) |> observe_view_model() + |> update_onboarding() |> update_selected_item_id() |> update_selected_item() |> update_start_view() @@ -47,6 +48,7 @@ defmodule Systems.Assignment.CrewPage do def handle_view_model_updated(socket) do socket + |> update_onboarding() |> update_selected_item_id() |> update_selected_item() |> update_start_view() @@ -54,6 +56,10 @@ defmodule Systems.Assignment.CrewPage do |> update_menus() end + defp update_onboarding(%{assigns: %{vm: %{onboarding: onboarding}}} = socket) do + socket |> assign(onboarding: onboarding) + end + defp update_selected_item_id(%{assigns: %{selected_item_id: selected_item_id}} = socket) when not is_nil(selected_item_id) do socket @@ -161,6 +167,16 @@ defmodule Systems.Assignment.CrewPage do {:noreply, socket} end + @impl true + def handle_info({:onboarding_continue, _}, %{assigns: %{onboarding: onboarding}} = socket) do + {_, onboarding} = List.pop_at(onboarding, 0) + + { + :noreply, + socket |> assign(onboarding: onboarding) + } + end + @impl true def handle_event( "work_item_selected", @@ -240,24 +256,28 @@ defmodule Systems.Assignment.CrewPage do def render(assigns) do ~H""" <.stripped menus={@menus} footer?={false}> -
- <%= if @work_list && @show_left_column do %> -
- <.work_list {@work_list} /> -
-
-
- <% end %> -
- <%= if @tool_ref_view do %> - <.tool_ref_view {@tool_ref_view}/> - <% else %> - <%= if @start_view do %> - <.start_view {@start_view} /> - <% end %> + <%= if view = List.first(@onboarding) do %> + <.live_component {view} /> + <% else %> +
+ <%= if @work_list && @show_left_column do %> +
+ <.work_list {@work_list} /> +
+
+
<% end %> +
+ <%= if @tool_ref_view do %> + <.tool_ref_view {@tool_ref_view}/> + <% else %> + <%= if @start_view do %> + <.start_view {@start_view} /> + <% end %> + <% end %> +
-
+ <% end %> """ end diff --git a/core/systems/assignment/crew_page_builder.ex b/core/systems/assignment/crew_page_builder.ex index 8f7387f1c..b7aff0b4f 100644 --- a/core/systems/assignment/crew_page_builder.ex +++ b/core/systems/assignment/crew_page_builder.ex @@ -2,35 +2,72 @@ defmodule Systems.Assignment.CrewPageBuilder do alias Systems.{ Assignment, Crew, - Workflow + Workflow, + Consent } - def view_model( - %{crew: crew, status: status} = assignment, - %{current_user: user} = _assigns - ) do - member = Crew.Public.get_member(crew, user) + def view_model(assignment, assigns) do + %{ + onboarding: onboarding(assignment, assigns), + items: items(assignment, assigns) + } + end + + defp onboarding(%{status: status} = assignment, assigns) do + if is_tester?(assignment, assigns) or status == :online do + onboarding(assignment, assigns, current_onboarding(assigns)) + else + [] + end + end - items = - if status == :online or Core.Authorization.user_has_role?(user, crew, :tester) do - items(assignment, member) - else - # offline mode - [] - end + defp onboarding(assignment, assigns, nil), do: full_onboarding(assignment, assigns) + + defp onboarding(assignment, assigns, current_onboarding) do + full_onboarding(assignment, assigns) + |> Enum.filter(fn %{id: id} -> + Enum.find(current_onboarding, &(&1.id == id)) != nil + end) + end + + defp full_onboarding(assignment, assigns) do + [consent_view(assignment, assigns)] + end + + defp current_onboarding(%{onboarding: onboarding}), do: onboarding + defp current_onboarding(_), do: nil + + defp consent_view(%{consent_agreement: consent_agreement}, %{current_user: user}) do + revision = Consent.Public.latest_revision(consent_agreement) %{ - items: items + id: :onboarding_consent_view, + module: Assignment.OnboardingConsentView, + revision: revision, + user: user } end - defp items(%{workflow: workflow} = assignment, member) do + defp items(%{status: status, crew: crew} = assignment, %{current_user: user} = assigns) do + if is_tester?(assignment, assigns) or status == :online do + member = Crew.Public.get_member(crew, user) + items(assignment, member) + else + [] + end + end + + defp items(%{workflow: workflow} = assignment, %{} = member) do ordered_items = Workflow.Model.ordered_items(workflow) Enum.map(ordered_items, &{&1, get_or_create_task(&1, assignment, member)}) end defp items(_assignment, nil), do: [] + defp is_tester?(%{crew: crew}, %{current_user: user}) do + Core.Authorization.user_has_role?(user, crew, :tester) + end + defp get_or_create_task(item, %{crew: crew} = assignment, member) do identifier = Assignment.Private.task_identifier(assignment, item, member) diff --git a/core/systems/assignment/gdpr_form.ex b/core/systems/assignment/gdpr_form.ex new file mode 100644 index 000000000..890a4f40f --- /dev/null +++ b/core/systems/assignment/gdpr_form.ex @@ -0,0 +1,45 @@ +defmodule Systems.Assignment.GdprForm do + use CoreWeb, :live_component + + alias Systems.{ + Consent + } + + @impl true + def update(%{id: id, entity: entity}, socket) do + { + :ok, + socket + |> assign( + id: id, + entity: entity + ) + |> update_consent_agreement() + } + end + + defp update_consent_agreement(%{assigns: %{entity: entity}} = socket) do + revision = Consent.Public.latest_unlocked_revision_safe(entity) + + consent_revision_form = %{ + id: :consent_revision, + module: Consent.RevisionForm, + entity: revision + } + + assign(socket, consent_revision_form: consent_revision_form) + end + + @impl true + def render(assigns) do + ~H""" +
+ + + Consent + <.live_component {@consent_revision_form} /> + +
+ """ + end +end diff --git a/core/systems/assignment/model.ex b/core/systems/assignment/model.ex index cb72b8ee5..f5c588313 100644 --- a/core/systems/assignment/model.ex +++ b/core/systems/assignment/model.ex @@ -10,13 +10,15 @@ defmodule Systems.Assignment.Model do alias Systems.{ Assignment, Workflow, - Budget + Budget, + Consent } schema "assignments" do field(:special, Ecto.Atom) field(:status, Ecto.Enum, values: Assignment.Status.values(), default: :concept) + belongs_to(:consent_agreement, Consent.AgreementModel) belongs_to(:info, Assignment.InfoModel) belongs_to(:workflow, Workflow.Model) belongs_to(:crew, Systems.Crew.Model) @@ -63,7 +65,7 @@ defmodule Systems.Assignment.Model do def flatten(assignment) do assignment - |> Map.take([:id, :info, :workflow, :crew, :budget, :excluded, :director]) + |> Map.take([:id, :consent_agreement, :info, :workflow, :crew, :budget, :excluded, :director]) |> Map.put(:tool, tool(assignment)) end @@ -77,6 +79,7 @@ defmodule Systems.Assignment.Model do def preload_graph(:down) do [ :excluded, + consent_agreement: [:revisions], info: [], crew: [:tasks, :members, :auth_node], workflow: Workflow.Model.preload_graph(:down), diff --git a/core/systems/assignment/onboarding_consent_view.ex b/core/systems/assignment/onboarding_consent_view.ex new file mode 100644 index 000000000..f4dbaeb00 --- /dev/null +++ b/core/systems/assignment/onboarding_consent_view.ex @@ -0,0 +1,58 @@ +defmodule Systems.Assignment.OnboardingConsentView do + use CoreWeb.LiveForm + + alias Systems.{ + Consent + } + + @impl true + def update(%{consent_clickwrap_view: :continue}, %{assigns: %{id: id}} = socket) do + send(self(), {:onboarding_continue, id}) + + { + :ok, + socket + } + end + + @impl true + def update(%{id: id, revision: revision, user: user}, socket) do + { + :ok, + socket + |> assign( + id: id, + revision: revision, + user: user + ) + |> update_clickwrap_view() + } + end + + defp update_clickwrap_view( + %{assigns: %{revision: revision, user: user, myself: myself}} = socket + ) do + clickwrap_view = %{ + id: :consent_clickwrap_view, + module: Consent.ClickWrapView, + revision: revision, + user: user, + target: myself + } + + assign(socket, clickwrap_view: clickwrap_view) + end + + @impl true + def render(assigns) do + ~H""" +
+ + + <%= dgettext("eyra-assignment", "onboarding.consent.title") %> + <.live_component {@clickwrap_view} /> + +
+ """ + end +end diff --git a/core/systems/consent/_public.ex b/core/systems/consent/_public.ex new file mode 100644 index 000000000..3a16c0ce1 --- /dev/null +++ b/core/systems/consent/_public.ex @@ -0,0 +1,159 @@ +defmodule Systems.Consent.Public do + import CoreWeb.Gettext + import Ecto.Query + + alias Ecto.Multi + alias Core.Repo + alias Frameworks.Signal + + alias Systems.{ + Consent + } + + def create_agreement(auth_node) do + prepare_agreement(auth_node) + |> Repo.insert() + end + + def prepare_agreement(auth_node) do + %Consent.AgreementModel{} + |> Consent.AgreementModel.changeset() + |> Ecto.Changeset.put_assoc(:auth_node, auth_node) + end + + def create_revision(source, agreement) do + prepare_revision(source, agreement) + |> Repo.insert() + end + + def prepare_revision(source, agreement) when is_binary(source) do + %Consent.RevisionModel{} + |> Consent.RevisionModel.changeset(%{source: source}) + |> Ecto.Changeset.put_assoc(:agreement, agreement) + end + + def create_signature(revision, user) do + prepare_signature(revision, user) + |> Repo.insert() + end + + def prepare_signature(revision, user) do + %Consent.SignatureModel{} + |> Consent.SignatureModel.changeset() + |> Ecto.Changeset.put_assoc(:revision, revision) + |> Ecto.Changeset.put_assoc(:user, user) + end + + def get_agreement!(id, preload \\ []) do + Repo.get!(Consent.AgreementModel, id) |> Repo.preload(preload) + end + + def get_revision!(id, preload \\ []) do + Repo.get!(Consent.RevisionModel, id) |> Repo.preload(preload) + end + + def has_signature(revision, user) do + get_signature(revision, user) != nil + end + + def get_signature(%Consent.RevisionModel{id: revision_id}, %Core.Accounts.User{id: user_id}) do + from(s in Consent.SignatureModel, + where: s.user_id == ^user_id, + where: s.revision_id == ^revision_id + ) + |> Repo.all() + |> List.first() + end + + def list_agreements(preload \\ []) do + from(a in Consent.AgreementModel, + order_by: {:desc, :inserted_at}, + preload: ^preload, + limit: 1 + ) + |> Repo.all() + end + + def latest_unlocked_revision_safe(agreement, preload \\ []) do + if revision = latest_unlocked_revision(agreement, preload) do + revision + else + source = + if revision = latest_revision(agreement, preload) do + revision.source + else + dgettext("eyra-consent", "default.consent.text") + end + + create_revision(source, agreement) + end + + query_unlocked_revisions(agreement, preload) + |> Repo.all() + |> List.first() + end + + def latest_unlocked_revision(agreement, preload \\ []) do + query_unlocked_revisions(agreement, preload) + |> Repo.all() + |> List.first() + end + + def query_unlocked_revisions(agreement, preload \\ []) do + from(revision in query_revisions(agreement, preload), + where: + revision.id not in subquery( + from(signature in Consent.SignatureModel, + select: signature.revision_id + ) + ) + ) + end + + def latest_revision(agreement, preload \\ []) do + query_revisions(agreement, preload) + |> Repo.all() + |> List.first() + end + + def query_revisions(agreement, preload \\ []) + + def query_revisions(%Consent.AgreementModel{id: agreement_id}, preload) do + query_revisions(agreement_id, preload) + end + + def query_revisions(agreement_id, preload) when is_integer(agreement_id) do + from(revision in Consent.RevisionModel, + where: revision.agreement_id == ^agreement_id, + order_by: {:desc, :id}, + preload: ^preload + ) + end + + def update_revision( + %Ecto.Changeset{data: %Consent.RevisionModel{id: id, updated_at: updated_at}} = changeset + ) do + Multi.new() + |> Multi.run(:validate_timestamp, fn _, _ -> + %{updated_at: stored_updated_at} = Consent.Public.get_revision!(id) + + if stored_updated_at == updated_at do + {:ok, :valid} + else + {:error, "Revision out of sync"} + end + end) + |> Multi.update(:consent_revision, changeset) + |> Signal.Public.multi_dispatch({:consent_revision, :updated}) + |> Repo.transaction() + end +end + +defimpl Core.Persister, for: Systems.Consent.RevisionModel do + def save(_revision, changeset) do + case Systems.Consent.Public.update_revision(changeset) do + {:ok, %{consent_revision: revision}} -> {:ok, revision} + _ -> {:error, changeset} + end + end +end diff --git a/core/systems/consent/_switch.ex b/core/systems/consent/_switch.ex new file mode 100644 index 000000000..43c6822ad --- /dev/null +++ b/core/systems/consent/_switch.ex @@ -0,0 +1,25 @@ +defmodule Systems.Consent.Switch do + use Frameworks.Signal.Handler + + alias Frameworks.{ + Signal + } + + alias Systems.{ + Consent + } + + @impl true + def intercept( + {:consent_revision, _} = signal, + %{consent_revision: %{agreement_id: agreement_id}} = message + ) do + consent_agreement = + Consent.Public.get_agreement!(agreement_id, Consent.AgreementModel.preload_graph(:down)) + + dispatch!( + {:consent_agreement, signal}, + Map.merge(message, %{consent_agreement: consent_agreement}) + ) + end +end diff --git a/core/systems/consent/agreement_model.ex b/core/systems/consent/agreement_model.ex new file mode 100644 index 000000000..2401c9882 --- /dev/null +++ b/core/systems/consent/agreement_model.ex @@ -0,0 +1,34 @@ +defmodule Systems.Consent.AgreementModel do + use Ecto.Schema + use Frameworks.Utility.Schema + + import Ecto.Changeset + + alias Systems.{ + Consent + } + + schema "consent_agreements" do + belongs_to(:auth_node, Core.Authorization.Node) + has_many(:revisions, Consent.RevisionModel, foreign_key: :agreement_id) + timestamps() + end + + @fields ~w()a + @required_fields ~w()a + + def preload_graph(:down), do: preload_graph([:auth_node]) + def preload_graph(:up), do: preload_graph([]) + def preload_graph(:revisions), do: [revisions: Consent.RevisionModel.preload_graph(:down)] + def preload_graph(:auth_node), do: [auth_node: []] + + def changeset(agreement, attrs \\ %{}) do + agreement + |> cast(attrs, @fields) + end + + def validate(changeset) do + changeset + |> validate_required(@required_fields) + end +end diff --git a/core/systems/consent/clickwrap_view.ex b/core/systems/consent/clickwrap_view.ex new file mode 100644 index 000000000..73000ffef --- /dev/null +++ b/core/systems/consent/clickwrap_view.ex @@ -0,0 +1,107 @@ +defmodule Systems.Consent.ClickWrapView do + # Clickwrap agreements are a type of electronic signature that involves a user + # clicking a simple button to accept the agreement. + + use CoreWeb.LiveForm + + alias Systems.{ + Consent + } + + @impl true + def update(%{id: id, revision: revision, user: user, target: target}, socket) do + signature = Consent.Public.get_signature(revision, user) + selected? = signature != nil + + { + :ok, + socket + |> assign( + id: id, + revision: revision, + user: user, + target: target, + signature: signature, + selected?: selected? + ) + |> update_form() + |> update_continue_button() + } + end + + defp update_form(%{assigns: %{selected?: selected?}} = socket) do + form = to_form(%{"signature_check" => selected?}) + assign(socket, form: form) + end + + defp update_continue_button(%{assigns: %{selected?: selected?, myself: myself}} = socket) do + continue_button = %{ + action: %{type: :send, event: "continue", target: myself}, + face: %{ + type: :primary, + label: dgettext("eyra-assignment", "onboarding.consent.continue.button") + }, + enabled?: selected? + } + + assign(socket, continue_button: continue_button) + end + + @impl true + def handle_event( + "toggle", + %{"checkbox" => _checkbox}, + %{assigns: %{selected?: selected?}} = socket + ) do + { + :noreply, + socket + |> assign(selected?: not selected?) + |> update_form() + |> update_continue_button() + } + end + + @impl true + def handle_event("continue", _payload, socket) do + { + :noreply, + socket |> handle_continue() + } + end + + def handle_continue(%{assigns: %{signature: nil, revision: revision, user: user}} = socket) do + {:ok, signature} = Consent.Public.create_signature(revision, user) + + socket + |> assign(signature: signature) + |> handle_continue() + end + + def handle_continue(%{assigns: %{id: id, signature: %{id: _}, target: target}} = socket) do + send_update(target, %{id => :continue}) + socket + end + + @impl true + def render(assigns) do + ~H""" +
+
+ <%= raw @revision.source %> +
+ <.spacing value="M" /> + <.form id={@id} :let={form} for={@form} phx-target={@myself} > + <.checkbox + form={form} + field={:signature_check} + label_text="I have read and agree with the above terms." + /> + + <.wrap> + + +
+ """ + end +end diff --git a/core/systems/consent/revision_form.ex b/core/systems/consent/revision_form.ex new file mode 100644 index 000000000..35b28f3c4 --- /dev/null +++ b/core/systems/consent/revision_form.ex @@ -0,0 +1,66 @@ +defmodule Systems.Consent.RevisionForm do + use CoreWeb.LiveForm + + alias Systems.{ + Consent + } + + @impl true + def update(%{id: id, entity: %{source: source} = entity}, socket) do + form = to_form(%{"source" => source}) + + { + :ok, + socket + |> assign( + id: id, + entity: entity, + form: form + ) + } + end + + @impl true + def handle_event( + "save", + %{"source_input" => source}, + %{assigns: %{entity: %{source: old_source} = entity}} = socket + ) do + { + :noreply, + if old_source == source do + socket + else + save(socket, entity, %{source: source}) + end + } + end + + # Saving + + def save(socket, entity, attrs) do + changeset = Consent.RevisionModel.changeset(entity, attrs) + + case Core.Persister.save(entity, changeset) do + {:ok, entity} -> + socket + |> assign(entity: entity) + |> flash_persister_saved() + + {:error, _} -> + socket + |> flash_persister_error(dgettext("eyra-consent", "consent-out-of-sync-error")) + end + end + + @impl true + def render(assigns) do + ~H""" +
+ <.form id="agreement_form" :let={form} for={@form} phx-change="save" phx-target={@myself} > + <.wysiwyg_area form={form} field={:source} /> + +
+ """ + end +end diff --git a/core/systems/consent/revision_model.ex b/core/systems/consent/revision_model.ex new file mode 100644 index 000000000..0a7fead57 --- /dev/null +++ b/core/systems/consent/revision_model.ex @@ -0,0 +1,36 @@ +defmodule Systems.Consent.RevisionModel do + use Ecto.Schema + use Frameworks.Utility.Schema + + import Ecto.Changeset + + alias Systems.{ + Consent + } + + schema "consent_revisions" do + field(:source, :string) + belongs_to(:agreement, Consent.AgreementModel) + has_many(:signatures, Consent.SignatureModel, foreign_key: :revision_id) + timestamps() + end + + @fields ~w(source)a + @required_fields ~w()a + + def preload_graph(:up), do: preload_graph([:agreement]) + def preload_graph(:down), do: preload_graph([:signatures]) + + def preload_graph(:agreement), do: [agreement: Consent.AgreementModel.preload_graph(:up)] + def preload_graph(:signatures), do: [signatures: Consent.SignatureModel.preload_graph(:down)] + + def changeset(revision, attrs) do + revision + |> cast(attrs, @fields) + end + + def validate(changeset) do + changeset + |> validate_required(@required_fields) + end +end diff --git a/core/systems/consent/signature_model.ex b/core/systems/consent/signature_model.ex new file mode 100644 index 000000000..f3b1f6042 --- /dev/null +++ b/core/systems/consent/signature_model.ex @@ -0,0 +1,35 @@ +defmodule Systems.Consent.SignatureModel do + use Ecto.Schema + use Frameworks.Utility.Schema + + import Ecto.Changeset + + alias Systems.{ + Consent + } + + schema "consent_signatures" do + belongs_to(:revision, Consent.RevisionModel) + belongs_to(:user, Core.Accounts.User) + timestamps() + end + + @fields ~w()a + @required_fields ~w()a + + def preload_graph(:up), do: preload_graph([:revision]) + def preload_graph(:down), do: preload_graph([:user]) + + def preload_graph(:revision), do: [revision: Consent.RevisionModel.preload_graph(:up)] + def preload_graph(:user), do: [user: []] + + def changeset(signature, attrs \\ %{}) do + signature + |> cast(attrs, @fields) + end + + def validate(changeset) do + changeset + |> validate_required(@required_fields) + end +end diff --git a/core/test/systems/consent/_public_test.exs b/core/test/systems/consent/_public_test.exs new file mode 100644 index 000000000..2426f3e92 --- /dev/null +++ b/core/test/systems/consent/_public_test.exs @@ -0,0 +1,197 @@ +defmodule Systems.Consent.PublicTest do + use Core.DataCase + alias Core.Authorization + alias Ecto.Multi + + alias Systems.Consent + + test "list/0 returns all created agreements" do + {:ok, %{id: id}} = + Authorization.prepare_node() + |> Consent.Public.create_agreement() + + assert [%Systems.Consent.AgreementModel{id: ^id}] = Consent.Public.list_agreements() + end + + test "list/0 returns all created agreements with revisions" do + {:ok, _} = + Multi.new() + |> Multi.insert(:agreement, Consent.Public.prepare_agreement(Authorization.prepare_node())) + |> Multi.insert(:revision1, fn %{agreement: agreement} -> + Consent.Public.prepare_revision("revision1", agreement) + end) + |> Multi.insert(:revision2, fn %{agreement: agreement} -> + Consent.Public.prepare_revision("revision2", agreement) + end) + |> Repo.transaction() + + assert [ + %Systems.Consent.AgreementModel{ + revisions: [ + %Systems.Consent.RevisionModel{ + source: "revision1" + }, + %Systems.Consent.RevisionModel{ + source: "revision2" + } + ] + } + ] = Consent.Public.list_agreements([:revisions]) + end + + test "list/0 returns all created agreements with revisions and signatures" do + %{id: user_a_id} = user_a = Factories.insert!(:member) + %{id: user_b_id} = user_b = Factories.insert!(:member) + %{id: user_c_id} = user_c = Factories.insert!(:member) + + {:ok, _} = + Multi.new() + |> Multi.insert(:agreement, Consent.Public.prepare_agreement(Authorization.prepare_node())) + |> Multi.insert(:revision1, fn %{agreement: agreement} -> + Consent.Public.prepare_revision("revision1", agreement) + end) + |> Multi.insert(:signatureA1, fn %{revision1: revision1} -> + Consent.Public.prepare_signature(revision1, user_a) + end) + |> Multi.insert(:signatureB1, fn %{revision1: revision1} -> + Consent.Public.prepare_signature(revision1, user_b) + end) + |> Multi.insert(:revision2, fn %{agreement: agreement} -> + Consent.Public.prepare_revision("revision2", agreement) + end) + |> Multi.insert(:signatureA2, fn %{revision2: revision2} -> + Consent.Public.prepare_signature(revision2, user_a) + end) + |> Multi.insert(:signatureB2, fn %{revision2: revision2} -> + Consent.Public.prepare_signature(revision2, user_b) + end) + |> Multi.insert(:signatureC2, fn %{revision2: revision2} -> + Consent.Public.prepare_signature(revision2, user_c) + end) + |> Repo.transaction() + + assert [ + %Systems.Consent.AgreementModel{ + revisions: [ + %Systems.Consent.RevisionModel{ + source: "revision1", + signatures: [ + %Systems.Consent.SignatureModel{user_id: ^user_a_id}, + %Systems.Consent.SignatureModel{user_id: ^user_b_id} + ] + }, + %Systems.Consent.RevisionModel{ + source: "revision2", + signatures: [ + %Systems.Consent.SignatureModel{user_id: ^user_a_id}, + %Systems.Consent.SignatureModel{user_id: ^user_b_id}, + %Systems.Consent.SignatureModel{user_id: ^user_c_id} + ] + } + ] + } + ] = Consent.Public.list_agreements(revisions: [:signatures]) + end + + test "latest_revision/2 returns nil" do + agreement = Factories.insert!(:consent_agreement) + assert Consent.Public.latest_revision(agreement) == nil + end + + test "latest_revision/2 returns latest revision " do + %{id: id} = agreement = Factories.insert!(:consent_agreement) + %{id: _revision_1_id} = Factories.insert!(:consent_revision, %{agreement: agreement}) + %{id: revision_2_id} = Factories.insert!(:consent_revision, %{agreement: agreement}) + + assert %Systems.Consent.RevisionModel{ + id: ^revision_2_id, + source: nil, + agreement: %Systems.Consent.AgreementModel{id: ^id} + } = Consent.Public.latest_revision(agreement, [:agreement]) + end + + test "latest_unlocked_revision/2 returns nil" do + user = Factories.insert!(:member) + + agreement = Factories.insert!(:consent_agreement) + revision_1 = Factories.insert!(:consent_revision, %{agreement: agreement}) + _signature = Factories.insert!(:consent_signature, %{revision: revision_1, user: user}) + + assert Consent.Public.latest_unlocked_revision(agreement, [:agreement]) == nil + end + + test "latest_unlocked_revision_safe/2 returns new revision in empty agreement" do + agreement = Factories.insert!(:consent_agreement) + + assert %Systems.Consent.RevisionModel{ + source: "
Beschrijf hier de consent voorwaarden
", + signatures: [] + } = Consent.Public.latest_unlocked_revision_safe(agreement, [:signatures]) + end + + test "latest_unlocked_revision_safe/2 returns latest revision" do + agreement = Factories.insert!(:consent_agreement) + _revision = Factories.insert!(:consent_revision, %{agreement: agreement, source: "source"}) + + assert %Systems.Consent.RevisionModel{ + source: "source", + signatures: [] + } = Consent.Public.latest_unlocked_revision_safe(agreement, [:signatures]) + end + + test "latest_unlocked_revision_safe/2 returns new revision on top of locked revision" do + user = Factories.insert!(:member) + + agreement = Factories.insert!(:consent_agreement) + revision_1 = Factories.insert!(:consent_revision, %{agreement: agreement, source: "source"}) + _signature = Factories.insert!(:consent_signature, %{revision: revision_1, user: user}) + + assert %Systems.Consent.RevisionModel{ + id: revision_2_id, + source: "source", + signatures: [] + } = Consent.Public.latest_unlocked_revision_safe(agreement, [:signatures]) + + assert revision_1.id != revision_2_id + end + + test "create_signature/2 succeeds" do + user = Factories.insert!(:member) + + agreement = Factories.insert!(:consent_agreement) + revision = Factories.insert!(:consent_revision, %{agreement: agreement}) + + assert {:ok, _} = Consent.Public.create_signature(revision, user) + end + + test "create_signature/2 fails when signature already exists" do + user = Factories.insert!(:member) + + agreement = Factories.insert!(:consent_agreement) + revision = Factories.insert!(:consent_revision, %{agreement: agreement}) + _signature = Factories.insert!(:consent_signature, %{revision: revision, user: user}) + + assert_raise Ecto.ConstraintError, fn -> Consent.Public.create_signature(revision, user) end + end + + test "has_signature/2 true" do + user = Factories.insert!(:member) + + agreement = Factories.insert!(:consent_agreement) + revision = Factories.insert!(:consent_revision, %{agreement: agreement}) + _signature = Factories.insert!(:consent_signature, %{revision: revision, user: user}) + + assert Consent.Public.has_signature(revision, user) + end + + test "has_signature/2 false" do + user_a = Factories.insert!(:member) + user_b = Factories.insert!(:member) + + agreement = Factories.insert!(:consent_agreement) + revision = Factories.insert!(:consent_revision, %{agreement: agreement}) + _signature = Factories.insert!(:consent_signature, %{revision: revision, user: user_a}) + + assert not Consent.Public.has_signature(revision, user_b) + end +end