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 @@
+
+
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