From 3357e61a8463e77f20e8359e27d65968e6c3e79c Mon Sep 17 00:00:00 2001 From: Doug Martin Date: Mon, 20 Nov 2023 06:49:57 -0500 Subject: [PATCH] feat: Added UI for showing/removing admins of a project [PT-186428490] Adds list of admins in the project edit form and allows users to remove project admins. Due to the concern of leaking emails only site admins can add project admins to projects. This also updates some styling of the main admin ui. --- app/assets/javascripts/lara-typescript.js | 132 +++++++--- app/assets/stylesheets/lara-typescript.css | 52 +++- app/assets/stylesheets/style.scss | 5 + app/controllers/api/v1/projects_controller.rb | 16 +- app/models/project.rb | 2 +- app/views/admin/users/_form.html.erb | 2 +- .../components/project-settings-form.scss | 59 ++++- .../components/project-settings-form.spec.tsx | 10 +- .../components/project-settings-form.tsx | 247 +++++++++++------- lara-typescript/src/projects/types.ts | 4 + .../api/v1/projects_controller_spec.rb | 31 ++- 11 files changed, 408 insertions(+), 152 deletions(-) diff --git a/app/assets/javascripts/lara-typescript.js b/app/assets/javascripts/lara-typescript.js index 13e1faff1..b895c86a0 100644 --- a/app/assets/javascripts/lara-typescript.js +++ b/app/assets/javascripts/lara-typescript.js @@ -124633,17 +124633,21 @@ var ProjectSettingsForm = function (_a) { project = _g[0], setProject = _g[1]; - var _h = (0, react_1.useState)(false), - projectLoaded = _h[0], - setProjectLoaded = _h[1]; + var _h = (0, react_1.useState)([]), + admins = _h[0], + setAdmins = _h[1]; var _j = (0, react_1.useState)(false), - isNewProject = _j[0], - setIsNewProject = _j[1]; + projectLoaded = _j[0], + setProjectLoaded = _j[1]; var _k = (0, react_1.useState)(false), - projectSaved = _k[0], - setProjectSaved = _k[1]; + isNewProject = _k[0], + setIsNewProject = _k[1]; + + var _l = (0, react_1.useState)(false), + projectSaved = _l[0], + setProjectSaved = _l[1]; (0, react_1.useEffect)(function () { if (id) { @@ -124685,6 +124689,7 @@ var ProjectSettingsForm = function (_a) { delete data.project.created_at; delete data.project.updated_at; setProject((0, convert_keys_1.snakeToCamelCaseKeys)(data.project)); + setAdmins(data.admins || []); setProjectLoaded(true); setPageTitle("Edit " + data.project.title); return [2 @@ -124741,11 +124746,9 @@ var ProjectSettingsForm = function (_a) { var handleCollaboratorsImageUrlChange = handleTextInputChange("collaboratorsImageUrl"); var handleContactEmailChange = handleTextInputChange("contactEmail"); var handleCopyrightImageUrlChange = handleTextInputChange("copyrightImageUrl"); - var handleFooterChange = handleTextareaChange("footer"); var handleFundersImageUrlChange = handleTextInputChange("fundersImageUrl"); var handleKeyChange = handleTextInputChange("projectKey"); var handleLogoApChange = handleTextInputChange("logoAp"); - var handleLogoLaraChange = handleTextInputChange("logoLara"); var handleTitleChange = handleTextInputChange("title"); var handleUrlChange = handleTextInputChange("url"); var handleAboutChange = handleSlateRteChange("about", setAboutValue); @@ -124760,6 +124763,9 @@ var ProjectSettingsForm = function (_a) { case 0: apiUrl = id ? "/api/v1/projects/" + id : "/api/v1/projects"; projectData = (0, convert_keys_1.camelToSnakeCaseKeys)(project); + projectData.admin_ids = admins.map(function (a) { + return a.id; + }); return [4 /*yield*/ , fetch(apiUrl, { @@ -124804,6 +124810,55 @@ var ProjectSettingsForm = function (_a) { }); }; + var handleRemoveAdmin = function (admin) { + return function (e) { + var title = project.title.trim().length > 0 ? project.title : "this project"; + + if (confirm("Are you sure you want to remove " + admin.email + " as a Project Admin of " + title + "?")) { + setAdmins(function (prev) { + return prev.filter(function (a) { + return a.id !== admin.id; + }); + }); + } + }; + }; + + var renderProjectAdmins = function () { + if (!id) { + return null; + } + + var renderList = function () { + if (!projectLoaded) { + return React.createElement("div", { + className: "emphasis" + }, "Loading the admin list ..."); + } + + if (admins.length === 0) { + return React.createElement("div", { + className: "emphasis" + }, "There are no project admins assigned to this project. Please contact a site admin to add project admins to this project."); + } + + return React.createElement("div", null, React.createElement("table", null, React.createElement("tbody", null, admins.map(function (admin) { + return React.createElement("tr", { + key: admin.id + }, React.createElement("td", null, admin.email), React.createElement("td", null, React.createElement("button", { + title: "Remove this project admin from the project", + onClick: handleRemoveAdmin(admin) + }, "DELETE"))); + }))), React.createElement("div", { + className: "emphasis" + }, "Please contact a site admin to add additional project admins to this project.")); + }; + + return React.createElement("div", { + className: "projectAdmins" + }, React.createElement("label", null, "Project Admins"), renderList()); + }; + if (isNewProject && projectSaved) { return null; } @@ -124816,11 +124871,16 @@ var ProjectSettingsForm = function (_a) { href: "/" }, "Home"), " "), React.createElement("li", null, "\u00A0/ ", React.createElement("a", { href: "/projects" - }, "Projects"), " "), React.createElement("li", null, "\u00A0/ ", pageTitle)))), React.createElement("h1", { - className: "title" - }, pageTitle), alertMessage && React.createElement("div", { + }, "Projects"), " "), React.createElement("li", null, "\u00A0/ ", pageTitle)))), React.createElement("div", { + className: "titleContainer" + }, React.createElement("h1", null, pageTitle), React.createElement("button", { + className: "save-button", + onClick: handleSaveProject + }, "Save")), alertMessage && React.createElement("div", { className: "alertMessage" - }, alertMessage), React.createElement("dl", null, React.createElement("dt", null, React.createElement("label", { + }, alertMessage), React.createElement("div", { + className: "splitForm" + }, React.createElement("dl", null, React.createElement("dt", null, React.createElement("label", { htmlFor: "project-title" }, "Title")), React.createElement("dd", null, React.createElement("input", { id: "project-title", @@ -124840,19 +124900,8 @@ var ProjectSettingsForm = function (_a) { })), React.createElement("dd", { className: "inputNote" }, "The project key is used across sites to synchronise project information. It must be a unique value."), React.createElement("dt", null, React.createElement("label", { - htmlFor: "project-logo-lara" - }, "LARA Runtime Logo URL")), React.createElement("dd", { - className: "hasNote" - }, React.createElement("input", { - id: "project-logo-lara", - name: "project[logo_lara]", - defaultValue: project.logoLara, - onChange: handleLogoLaraChange - })), React.createElement("dd", { - className: "inputNote" - }, "Image should be 228 pixels wide by 70 pixels high. If left blank, the Concord Consortium logo will be used by default."), React.createElement("dt", null, React.createElement("label", { htmlFor: "project-logo-ap" - }, "Activity Player Logo URL")), React.createElement("dd", { + }, "Activity Header Logo URL")), React.createElement("dd", { className: "hasNote" }, React.createElement("input", { id: "project-logo-ap", @@ -124872,18 +124921,7 @@ var ProjectSettingsForm = function (_a) { onChange: handleUrlChange })), React.createElement("dd", { className: "inputNote" - }, "When logo image is clicked, this is URL will launch in a new browser tab."), React.createElement("dt", null, React.createElement("label", { - htmlFor: "project-url" - }, "LARA Runtime Footer")), React.createElement("dd", { - className: "hasNote" - }, React.createElement("textarea", { - id: "project-footer", - name: "project[footer]", - defaultValue: project.footer, - onChange: handleFooterChange - })), React.createElement("dd", { - className: "inputNote" - }, "Raw HTML can be entered into this legacy field.")), React.createElement("h2", null, "Activity Player Footer"), React.createElement("dl", null, React.createElement("dt", null, React.createElement("label", null, "Copyright/Attribution Text")), React.createElement("dd", null, React.createElement("div", { + }, "When logo image is clicked, this is URL will launch in a new browser tab.")), renderProjectAdmins()), React.createElement("h2", null, "Activity Homepage Footer"), React.createElement("dl", null, React.createElement("dt", null, React.createElement("label", null, "Copyright/Attribution Text")), React.createElement("dd", null, React.createElement("div", { className: "slateContainer" }, React.createElement(slate_editor_1.SlateContainer, { value: copyrightValue, @@ -124943,8 +124981,24 @@ var ProjectSettingsForm = function (_a) { onChange: handleContactEmailChange })), React.createElement("dd", { className: "inputNote" - }, "Provide a valid email address for users to contact your project team. When this is provided, your contact email will be displayed in the footer.")), React.createElement("button", { - id: "save-button", + }, "Provide a valid email address for users to contact your project team. When this is provided, your contact email will be displayed in the footer.")), React.createElement("h2", null, "Legacy LARA Fields"), React.createElement("dl", null, React.createElement("dd", { + className: "inputNote" + }, "These fields are no longer used but are shown here in case you need to see or copy their values."), React.createElement("dt", null, React.createElement("label", { + htmlFor: "project-logo-lara" + }, "LARA Runtime Logo URL")), React.createElement("dd", null, React.createElement("input", { + id: "project-logo-lara", + name: "project[logo_lara]", + defaultValue: project.logoLara, + disabled: true + })), React.createElement("dt", null, React.createElement("label", { + htmlFor: "project-url" + }, "LARA Runtime Footer")), React.createElement("dd", null, React.createElement("textarea", { + id: "project-footer", + name: "project[footer]", + defaultValue: project.footer, + disabled: true + }))), React.createElement("button", { + className: "save-button", onClick: handleSaveProject }, "Save")); }; diff --git a/app/assets/stylesheets/lara-typescript.css b/app/assets/stylesheets/lara-typescript.css index c8113b96b..a2580829e 100644 --- a/app/assets/stylesheets/lara-typescript.css +++ b/app/assets/stylesheets/lara-typescript.css @@ -4097,13 +4097,57 @@ button.bigButton svg { .projectSettingsForm { padding-bottom: 20px; } +.projectSettingsForm .titleContainer { + padding: 1% 0; + display: flex; + justify-content: space-between; + align-items: center; +} +.projectSettingsForm .titleContainer h1, .projectSettingsForm .titleContainer button { + margin: 0; +} .projectSettingsForm h2 { color: var(--teal); font-family: var(--font-family-bold); font-size: 20px; line-height: 1.2; - margin: 30px 0 20px; + margin: 20px 0; + padding: 0; +} +.projectSettingsForm .splitForm { + display: flex; + gap: 20px; +} +.projectSettingsForm .splitForm dl { + flex-grow: 1; + width: 100%; +} +.projectSettingsForm .splitForm .projectAdmins { + flex-grow: 1; + width: 25%; +} +.projectSettingsForm .splitForm .projectAdmins label { + font-size: 16px; + font-weight: 900; +} +.projectSettingsForm .splitForm .projectAdmins table { + margin: 10px 0; + width: 100%; + border-spacing: 10px; +} +.projectSettingsForm .splitForm .projectAdmins table button { + background: transparent; + border: none; + color: var(--dark-gray); + display: inline-block; + font-size: 12px; + margin: 0; padding: 0; + text-transform: uppercase; +} +.projectSettingsForm .splitForm .projectAdmins .emphasis { + margin: 10px 0; + font-style: italic; } .projectSettingsForm .slateContainer { border: solid 1.5px var(--med-gray); @@ -4142,8 +4186,8 @@ button.bigButton svg { border-radius: 4px; font-family: var(--font-family-default); font-size: 16px; - padding: 5px 10px 7px; - width: calc(50% - 23px); + padding: 5px 10px; + width: calc(100% - 23px); -webkit-appearance: none; } .projectSettingsForm textarea { @@ -4151,7 +4195,7 @@ button.bigButton svg { width: calc(100% - 23px); } .projectSettingsForm dl { - margin: 0 0 20px; + margin: 0; padding: 0; } .projectSettingsForm dl dt { diff --git a/app/assets/stylesheets/style.scss b/app/assets/stylesheets/style.scss index 138949e84..cc1ece63c 100644 --- a/app/assets/stylesheets/style.scss +++ b/app/assets/stylesheets/style.scss @@ -1227,6 +1227,11 @@ body.admin.edit, body.admin.new { padding: 7px; } } + input[type=checkbox] { + vertical-align: middle; + position: relative; + bottom: 1px; + } div.actions { padding: 7px; input { diff --git a/app/controllers/api/v1/projects_controller.rb b/app/controllers/api/v1/projects_controller.rb index 35a65701c..a4cde87ae 100644 --- a/app/controllers/api/v1/projects_controller.rb +++ b/app/controllers/api/v1/projects_controller.rb @@ -12,9 +12,9 @@ def index # GET /api/v1/projects/1.json def show begin - @project = Project.find(params[:id]) + @project = Project.includes(:admins).find(params[:id]) authorize! :manage, @project - render json: {project: @project}, status: 200 + render json: {project: @project, admins: admin_json(@project)}, status: 200 rescue ActiveRecord::RecordNotFound render json: {error: "Project not found"}, status: 404 end @@ -36,8 +36,12 @@ def update @updated_project_hash = params[:project] @project = Project.find(@updated_project_hash[:id]); authorize! :update, @project + + # remove any extra admin ids in the update that are not in the original, to ensure we can only remove and not add project admins + @updated_project_hash[:admin_ids] = (@updated_project_hash[:admin_ids] || []).select { |id| @project.admin_ids.include?(id.to_i) } + if @project.update_attributes(@updated_project_hash) - render json: {success: true, project: @project}, status: :ok + render json: {success: true, project: @project, admins: admin_json(@project)}, status: :ok else render json: @project.errors, status: :unprocessable_entity end @@ -54,4 +58,10 @@ def destroy end end + private + + def admin_json(project) + project.admins.map { |a| {id: a.id, email: a.email} } + end + end diff --git a/app/models/project.rb b/app/models/project.rb index beef2af9f..baee61f50 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -3,7 +3,7 @@ class Project < ActiveRecord::Base DefaultKey = 'default-project' attr_accessible :footer, :logo_lara, :logo_ap, :title, :url, :about, :project_key, :copyright, - :copyright_image_url, :collaborators, :funders_image_url, :collaborators_image_url, :contact_email + :copyright_image_url, :collaborators, :funders_image_url, :collaborators_image_url, :contact_email, :admin_ids validates :project_key, uniqueness: true has_many :sequences has_many :lightweight_activities diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index 805f8034f..97a506161 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -15,7 +15,7 @@
<%= f.check_box :is_admin %>
diff --git a/lara-typescript/src/projects/components/project-settings-form.scss b/lara-typescript/src/projects/components/project-settings-form.scss index 9a332c854..7d2f296b0 100644 --- a/lara-typescript/src/projects/components/project-settings-form.scss +++ b/lara-typescript/src/projects/components/project-settings-form.scss @@ -3,15 +3,66 @@ .projectSettingsForm { padding-bottom: 20px; + .titleContainer { + padding: 1% 0; + display: flex; + justify-content: space-between; + align-items: center; + + h1, button { + margin: 0; + } + } + h2 { color: var(--teal); font-family: var(--font-family-bold); font-size: 20px; line-height: 1.2; - margin: 30px 0 20px; + margin: 20px 0; padding: 0; } + .splitForm { + display: flex; + gap: 20px; + + dl { + flex-grow: 1; + width: 100%; + } + .projectAdmins { + flex-grow: 1; + width: 25%; + + label { + font-size: 16px; + font-weight: 900; + } + table { + margin: 10px 0; + width: 100%; + border-spacing: 10px; + + button { + background: transparent; + border: none; + color: var(--dark-gray); + display: inline-block; + font-size: 12px; + margin: 0; + padding: 0; + text-transform: uppercase; + } + } + .emphasis { + margin: 10px 0; + font-style: italic; + } + } + } + + .slateContainer { border: solid 1.5px var(--med-gray); border-radius: 4px; @@ -46,8 +97,8 @@ border-radius: 4px; font-family: var(--font-family-default); font-size: 16px; - padding: 5px 10px 7px; - width: calc(50% - 23px); // 20px padding + 3px border = 23 + padding: 5px 10px; + width: calc(100% - 23px); // 20px padding + 3px border = 23 -webkit-appearance: none; } textarea { @@ -56,7 +107,7 @@ } dl { - margin: 0 0 20px; + margin: 0; padding: 0; dt { diff --git a/lara-typescript/src/projects/components/project-settings-form.spec.tsx b/lara-typescript/src/projects/components/project-settings-form.spec.tsx index d9e5af9c4..ff9c906b5 100644 --- a/lara-typescript/src/projects/components/project-settings-form.spec.tsx +++ b/lara-typescript/src/projects/components/project-settings-form.spec.tsx @@ -58,7 +58,7 @@ describe("ProjectSettingsForm", () => { }); it("renders a form with options for project settings", async () => { - fetch.mockResponse(JSON.stringify({project})); + fetch.mockResponse(JSON.stringify({project, admins: []})); await act(async () => { ReactDOM.render(, container); }); @@ -90,14 +90,14 @@ describe("ProjectSettingsForm", () => { }); it("saves changes to project settings", async () => { - fetch.mockResponse(JSON.stringify({project})); + fetch.mockResponse(JSON.stringify({project, admins: []})); await act(async () => { ReactDOM.render(, container); }); const titleInput = container.querySelector("#project-title") as HTMLInputElement; const urlInput = container.querySelector("#project-url") as HTMLInputElement; - const saveButton = container.querySelector("#save-button") as HTMLButtonElement; - const updatedProject = {...project, title: "Test Project A", url: "https://concord.org/new-path"}; + const saveButton = container.querySelector(".save-button") as HTMLButtonElement; + const updatedProject = {...project, title: "Test Project A", url: "https://concord.org/new-path", admins: []}; fetch.mockResponse(JSON.stringify({project: updatedProject, success: true})); await act(async () => { fireEvent.change(titleInput, { target: { value: "Test Project A"} }); @@ -132,7 +132,7 @@ describe("ProjectSettingsForm", () => { ReactDOM.render(, container); }); const titleInput = container.querySelector("#project-title") as HTMLInputElement; - const saveButton = container.querySelector("#save-button") as HTMLButtonElement; + const saveButton = container.querySelector(".save-button") as HTMLButtonElement; await act(async () => { fireEvent.change(titleInput, { target: { value: "New Project Title"} }); fireEvent.blur(titleInput); diff --git a/lara-typescript/src/projects/components/project-settings-form.tsx b/lara-typescript/src/projects/components/project-settings-form.tsx index b4f97f71f..ccf760714 100644 --- a/lara-typescript/src/projects/components/project-settings-form.tsx +++ b/lara-typescript/src/projects/components/project-settings-form.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { useEffect, useState } from "react"; import { SlateContainer, slateToHtml, htmlToSlate } from "@concord-consortium/slate-editor"; -import { IProject } from "../types"; +import { IProject, IProjectAdmin } from "../types"; import { camelToSnakeCaseKeys, snakeToCamelCaseKeys } from "../../shared/convert-keys"; import "@concord-consortium/slate-editor/build/index.css"; @@ -35,6 +35,7 @@ export const ProjectSettingsForm: React.FC = ({id}: I const [copyrightValue, setCopyrightValue] = useState(htmlToSlate("")); const [pageTitle, setPageTitle] = useState("New Project"); const [project, setProject] = useState(newProject); + const [admins, setAdmins] = useState([]); const [projectLoaded, setProjectLoaded] = useState(false); const [isNewProject, setIsNewProject] = useState(false); const [projectSaved, setProjectSaved] = useState(false); @@ -69,6 +70,7 @@ export const ProjectSettingsForm: React.FC = ({id}: I delete data.project.created_at; delete data.project.updated_at; setProject(snakeToCamelCaseKeys(data.project)); + setAdmins(data.admins || []); setProjectLoaded(true); setPageTitle(`Edit ${data.project.title}`); }; @@ -110,11 +112,9 @@ export const ProjectSettingsForm: React.FC = ({id}: I const handleCollaboratorsImageUrlChange = handleTextInputChange("collaboratorsImageUrl"); const handleContactEmailChange = handleTextInputChange("contactEmail"); const handleCopyrightImageUrlChange = handleTextInputChange("copyrightImageUrl"); - const handleFooterChange = handleTextareaChange("footer"); const handleFundersImageUrlChange = handleTextInputChange("fundersImageUrl"); const handleKeyChange = handleTextInputChange("projectKey"); const handleLogoApChange = handleTextInputChange("logoAp"); - const handleLogoLaraChange = handleTextInputChange("logoLara"); const handleTitleChange = handleTextInputChange("title"); const handleUrlChange = handleTextInputChange("url"); const handleAboutChange = handleSlateRteChange("about", setAboutValue); @@ -124,6 +124,7 @@ export const ProjectSettingsForm: React.FC = ({id}: I const handleSaveProject = async () => { const apiUrl = id ? `/api/v1/projects/${id}` : `/api/v1/projects`; const projectData = camelToSnakeCaseKeys(project); + projectData.admin_ids = admins.map(a => a.id); const data = await fetch(apiUrl, { method: "POST", @@ -150,6 +151,63 @@ export const ProjectSettingsForm: React.FC = ({id}: I window.scrollTo(0, 0); }; + const handleRemoveAdmin = (admin: IProjectAdmin) => (e: React.MouseEvent) => { + const title = project.title.trim().length > 0 ? project.title : "this project"; + if (confirm(`Are you sure you want to remove ${admin.email} as a Project Admin of ${title}?`)) { + setAdmins(prev => prev.filter(a => a.id !== admin.id)); + } + }; + + const renderProjectAdmins = () => { + if (!id) { + return null; + } + + const renderList = () => { + if (!projectLoaded) { + return
Loading the admin list ...
; + } + + if (admins.length === 0) { + return ( +
+ There are no project admins assigned to this project. + Please contact a site admin to add project admins to this project. +
+ ); + } + + return ( +
+ + + {admins.map(admin => ( + + + + + ))} + +
{admin.email} + +
+
Please contact a site admin to add additional project admins to this project.
+
+ ); + }; + + return ( +
+ + {renderList()} +
+ ); + }; + if (isNewProject && projectSaved) { return(null); } @@ -165,95 +223,72 @@ export const ProjectSettingsForm: React.FC = ({id}: I
-

{pageTitle}

+
+

{pageTitle}

+ +
{alertMessage &&
{alertMessage}
} -
-
- -
-
- -
-
- -
-
- -
-
- The project key is used across sites to synchronise project information. It must be a unique value. -
-
- -
-
- -
-
- Image should be 228 pixels wide by 70 pixels high. If left blank, the Concord Consortium - logo will be used by default. -
-
- -
-
- -
-
- Image should be 250 pixels wide by 78 pixels high. If left blank, the Concord Consortium - logo will be used by default. -
-
- -
-
- -
-
- When logo image is clicked, this is URL will launch in a new browser tab. -
-
- -
-
-