diff --git a/src/videoag/form/TypeEditor.tsx b/src/videoag/form/TypeEditor.tsx index 8c934e9914a092dad3a3c9b09151ada2828b9633..176077727c773883f58512850abab67207b91c73 100644 --- a/src/videoag/form/TypeEditor.tsx +++ b/src/videoag/form/TypeEditor.tsx @@ -1,19 +1,9 @@ import React, { useEffect, useRef, useState } from "react"; -import Dropdown from "react-bootstrap/Dropdown"; -import type { - GetUsersResponse, - field_description, - view_permissions, - user, - int, -} from "@/videoag/api/types"; -import { useApi } from "@/videoag/api/ApiProvider"; -import { showError } from "@/videoag/error/ErrorDisplay"; +import type { int } from "@/videoag/api/types"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; -import { deepEquals } from "@/videoag/miscellaneous/Util"; -type EditorArgs = { +export type EditorArgs = { value?: any; //affectNow specifies whether value should be applied (e.g. reload data, apply filter) immediately (e.g. user hit enter) //or a bit later (debounced) (e.g. user typed in text field) @@ -23,79 +13,6 @@ type EditorArgs = { hideErrors?: boolean; }; -export function OMFieldEditor({ - description, - ...args -}: { - description: field_description; -} & EditorArgs) { - const { value } = args; - switch (description.type) { - // TODO check which types actually exist now - case "string": - return ( - <StringEditor - {...args} - minLength={description.min_length} - maxLength={description.max_length} - regex={description.pattern ? new RegExp(description.pattern) : undefined} - regexInvalidMessage={ - description.pattern && - `Invalid value. Must match pattern: ${description.pattern}` - } - /> - ); - - case "boolean": - return <BooleanEditor {...args} />; - - case "int": - return ( - <IntEditor - {...args} - minValue={description.min_value} - maxValue={description.max_value} - /> - ); - - case "datetime": - return <DatetimeEditor {...args} mayBeNull={description.may_be_null} />; - - case "enum": - return ( - <ChooserEditor - {...args} - langKeyPrefix={`enum.${description.name!}`} - possibleValues={description.enums!} - /> - ); - - case "view_permissions": - return <ViewPermissionsEditor {...args} />; - - case "object_list": - // TODO other objects - if (description.object_type === "user") { - return <UserIdListEditor {...args} />; - } - return ( - <span> - Unknown field type {description.type}. {JSON.stringify(value)} - </span> - ); - - // TODO media_process - // TODO object - - default: - return ( - <span> - Unknown field type {description.type}. {JSON.stringify(value)} - </span> - ); - } -} - export function StringEditor({ value, updateValue, @@ -427,423 +344,3 @@ export function ChooserEditor({ </select> ); } - -export function UserIdListEditor({ - value, - updateValue, - disabled, - autoFocus, - hideErrors, -}: EditorArgs) { - const api = useApi(); - const knownValue = useRef<int[]>(); - const [users, setUsers] = useState<GetUsersResponse>(); - const [search, setSearch] = useState<string>(""); - - useEffect(() => { - if (knownValue.current === value) return; - // Value was not changed by us, reset internal state - knownValue.current = value; - setUsers(undefined); - setSearch(""); - api.getUsers() - .then(setUsers) - .catch((err) => { - showError(err, "Unable to load users"); - }); - }, [api, knownValue, value]); - - useEffect(() => { - if (value !== undefined) return; - const newValue: int[] = []; - knownValue.current = newValue; - updateValue(newValue, true, true); - }); - - const selectedArray = value ?? []; - - let userList = selectedArray.length > 0 ? selectedArray.join(", ") : "None"; - if (users !== undefined && selectedArray.length > 0) { - userList = selectedArray - .map((id: int) => { - const user = users.users.find((u: user) => u.id === id); - if (user === undefined) return `#${id}`; - return `${user.display_name} (${user.handle}#${user.id})`; - }) - .join(", "); - } - if (userList.length > 100) userList = userList.slice(0, 100) + "..."; - - const searchTerm = search.toLowerCase(); - - const userElements = (users === undefined ? [] : users.users).flatMap((user: user) => { - if ( - !user.display_name.toLowerCase().includes(searchTerm) && - !user.full_name.toLowerCase().includes(searchTerm) && - !user.handle.toLowerCase().includes(searchTerm) - ) - return []; - const toggle = () => { - const index = selectedArray.indexOf(user.id); - let newArray = selectedArray.slice(); - if (index === -1) { - newArray.splice(newArray.length, 0, user.id); //Add user.id at end - } else { - newArray.splice(index, 1); //Remove user.id - } - knownValue.current = newArray; - updateValue(newArray, true, true); - }; - return [ - <tr key={user.id} className={selectedArray.includes(user.id) ? "table-primary" : ""}> - <td> - <input - type="checkbox" - checked={selectedArray.includes(user.id)} - onChange={toggle} - /> - </td> - <td onClick={toggle} className="pe-auto" style={{ cursor: "pointer" }}> - <span>{`${user.display_name} (${user.handle}#${user.id})`}</span> - </td> - </tr>, - ]; - }); - - return ( - <Dropdown> - <Dropdown.Toggle variant="primary" style={{ textWrap: "wrap" }} disabled={disabled}> - {userList} - </Dropdown.Toggle> - <Dropdown.Menu className="p-1"> - <div className="input-group mb-2 p-1"> - <span className="input-group-text"> - <i className="bi bi-search" /> - </span> - <input - type="text" - className="form-control" - placeholder="Username" - value={search} - autoFocus - onChange={(e) => setSearch(e.target.value)} - /> - <button - type="button" - className="btn btn-outline-secondary" - onClick={() => setSearch("")} - disabled={search === ""} - > - <i className="bi bi-x-circle" /> - </button> - </div> - {users === undefined ? ( - <div className="spinner-border" role="status" /> - ) : ( - <> - {userElements.length === 0 && ( - <div className="text-center">No users found</div> - )} - <div className="overflow-auto" style={{ maxHeight: "500px" }}> - <table className="table table-sm table-hover w-100"> - <thead> - <tr> - <th></th> - <th className="w-100"></th> - </tr> - </thead> - <tbody>{userElements}</tbody> - </table> - </div> - </> - )} - </Dropdown.Menu> - </Dropdown> - ); -} - -function createListEditor<V>( - disabled: boolean, - list: V[], - defaultNewValue: V, - onNewValueList: (newList: V[]) => void, - elementFactory: (value: V, onNewValue: (newVal: V) => void) => React.ReactNode, -) { - return ( - <> - {list.map((value: V, index: int) => ( - <div key={index} className="d-block"> - {elementFactory(value, (newVal: V) => - onNewValueList( - list.map((subValue: V, subIndex: int) => - index === subIndex ? newVal : subValue, - ), - ), - )} - <button - type="button" - className={"btn btn-outline-danger m-1"} - onClick={(e) => { - const newList = list.slice(); - newList.splice(index, 1); - onNewValueList(newList); - }} - title="Delete" - > - <i className="bi bi-trash-fill" /> - </button> - </div> - ))} - <button - className="btn btn-secondary" - type="button" - title="Add entry" - onClick={(e) => { - onNewValueList(list.concat([defaultNewValue])); - }} - disabled={disabled} - > - + - </button> - </> - ); -} - -export function ViewPermissionsEditor({ - value, - updateValue, - disabled, - autoFocus, - hideErrors, -}: EditorArgs) { - const permTypes = ["inherit", "public", "private", "authentication"]; - - // We need to store the current value locally since there is no order for the passwords and it would be really - // annoying if the order changes while typing - // This also gives the benefit of storing values for types which are currently not selected - type view_permissions_with_invalid = Omit<view_permissions, "passwords"> & { - passwords?: null | { [key: string]: string }; - }; - const { language } = useLanguage(); - const knownValue = useRef<view_permissions_with_invalid>(); - const [allDataValue, setAllDataValue] = useState<view_permissions_with_invalid>({ - type: "inherit", - }); - const [passwordsArray, setPasswordsArray] = useState<[string, string][]>([]); - - useEffect(() => { - if (deepEquals(value, knownValue.current)) return; - // Value was not changed by us, update internal state - const effectiveValue = value ?? { type: "inherit" }; - const passwordsMap = effectiveValue.passwords ?? {}; - setPasswordsArray( - Object.keys(passwordsMap).map((username: string) => [username, passwordsMap[username]]), - ); - setAllDataValue(effectiveValue); - knownValue.current = effectiveValue; - if (effectiveValue !== value) updateValue(effectiveValue, true, true); - }, [value, updateValue]); - - const updateData = (key: string, newData: any) => - setAllDataValue((prev: view_permissions_with_invalid | undefined) => { - let isValid = true; - - prev = prev ?? { type: "inherit" }; - let newAllData: view_permissions_with_invalid = { ...prev, [key]: newData }; - if (key === "passwords") { - setPasswordsArray(newData); - let newMap: { [key: string]: string } | null = {}; - for (let ele of newData) { - if (newMap[ele[0]] !== undefined) { - newMap = null; - break; - } - newMap[ele[0]] = ele[1]; - } - newAllData = { ...prev, passwords: newMap }; - } - - let newValue: view_permissions_with_invalid = { type: newAllData.type }; - if (newValue.type === "authentication") { - newValue.rwth_authentication = newAllData.rwth_authentication ?? false; - newValue.fsmpi_authentication = newAllData.fsmpi_authentication ?? false; - newValue.moodle_course_ids = newAllData.moodle_course_ids ?? []; - newValue.passwords = newAllData.passwords !== undefined ? newAllData.passwords : {}; - if (newValue.passwords === null) { - isValid = false; - } else if ( - !newValue.rwth_authentication && - !newValue.fsmpi_authentication && - newValue.moodle_course_ids.length === 0 && - Object.keys(newValue.passwords).length === 0 - ) { - isValid = false; - } - } - knownValue.current = newValue; - updateValue(newValue, isValid, true); - return newAllData; - }); - - let invalidMessage = undefined; - if ( - hideErrors !== true && - allDataValue?.type === "authentication" && - allDataValue.passwords !== null && - !( - allDataValue.rwth_authentication === true || - allDataValue.fsmpi_authentication === true || - (allDataValue.moodle_course_ids ?? []).length > 0 || - Object.keys(allDataValue.passwords ?? {}).length > 0 - ) - ) { - invalidMessage = ( - <div className="d-block invalid-feedback"> - Für Typ <i>Authentifizierung</i> muss mindestens eine Authentifizierungsmethode - vorhanden sein - </div> - ); - } - - let passwordsInvalidMessage; - if (hideErrors !== true && allDataValue?.type === "authentication") { - const passwordsMap: { [key: string]: null } = {}; - for (let ele of passwordsArray) { - if (passwordsMap[ele[0]] !== undefined) { - passwordsInvalidMessage = ( - <div className="d-block invalid-feedback"> - Doppelter Nutzername '{ele[0]}' - </div> - ); - break; - } - passwordsMap[ele[0]] = null; - } - } - - return ( - <div className="d-inline-grid"> - <select - className="w-auto form-select" - value={allDataValue?.type ?? "inherit"} - onChange={(e) => updateData("type", e.target.value)} - disabled={disabled} - autoFocus={autoFocus} - > - {permTypes.map((type) => ( - <option key={type} value={type}> - {language.get(`ui.view_permissions.type.${type}`)} - </option> - ))} - </select> - {allDataValue?.type === "authentication" && ( - <table className="w-100 table"> - <thead> - <tr> - <th scope="col" style={{ width: "30%" }} /> - <th scope="col" style={{ width: "70%" }} /> - </tr> - </thead> - <tbody> - <tr> - <td>RWTH authentication</td> - <td> - <input - type="checkbox" - className="form-check-input" - name="rwth_authentication" - defaultChecked={allDataValue?.rwth_authentication ?? false} - disabled={disabled} - onChange={(e) => - updateData("rwth_authentication", e.target.checked) - } - /> - </td> - </tr> - <tr> - <td>FSMPI authentication</td> - <td> - <input - type="checkbox" - className="form-check-input" - name="fsmpi_authentication" - defaultChecked={allDataValue?.fsmpi_authentication ?? false} - disabled={disabled} - onChange={(e) => - updateData("fsmpi_authentication", e.target.checked) - } - /> - </td> - </tr> - <tr> - <td>Moodle course ids</td> - <td> - {createListEditor<int>( - disabled ?? false, - allDataValue?.moodle_course_ids ?? [], - 0, - (newList: int[]) => updateData("moodle_course_ids", newList), - (value: int, onNewValue: (newVal: int) => void) => ( - <input - className="mb-1" - type="number" - value={value} - onChange={(e) => { - onNewValue(parseInt(e.target.value)); - }} - disabled={disabled ?? false} - /> - ), - )} - </td> - </tr> - <tr> - <td>Passwords</td> - <td> - <div className="d-inline-grid"> - <span> - {createListEditor<[string, string]>( - disabled ?? false, - passwordsArray, - ["", ""], - (newList: [string, string][]) => - updateData("passwords", newList), - ( - value: [string, string], - onNewValue: (newVal: [string, string]) => void, - ) => ( - <> - <input - className="me-1" - type="text" - placeholder="Username" - value={value[0]} - onChange={(e) => - onNewValue([e.target.value, value[1]]) - } - disabled={disabled ?? false} - /> - <input - type="text" - placeholder="Password" - value={value[1]} - onChange={(e) => - onNewValue([value[0], e.target.value]) - } - disabled={disabled ?? false} - /> - </> - ), - )} - </span> - {passwordsInvalidMessage} - </div> - </td> - </tr> - </tbody> - </table> - )} - {invalidMessage} - </div> - ); -} diff --git a/src/videoag/miscellaneous/Util.tsx b/src/videoag/miscellaneous/Util.tsx index 21b6d0a7969a8d1978b6bbcb9ab4198a698538e3..9fc94cc2bfc42f8d3dd618371e873c30cd2ced39 100644 --- a/src/videoag/miscellaneous/Util.tsx +++ b/src/videoag/miscellaneous/Util.tsx @@ -1,5 +1,6 @@ import type React from "react"; import { OverlayTrigger, Tooltip } from "react-bootstrap"; +import { toast, Bounce } from "react-toastify"; export function deepEquals(value: any, other: any) { if (value === null || other === null) { @@ -46,6 +47,18 @@ export function mapMap<K, V, R>(map: Map<K, V>, func: (val: V) => R): Map<K, R> return newMap; } +export function showInfoToast(message: React.ReactNode, autoClose: boolean) { + toast.info(message, { + position: "top-center", + autoClose: autoClose, + closeOnClick: false, + draggable: false, + progress: undefined, + theme: "light", // Colors overridden in css + transition: Bounce, + }); +} + export function TooltipButton({ children, iconClassName, diff --git a/src/videoag/object_management/OMConfigComponent.tsx b/src/videoag/object_management/OMConfigComponent.tsx index a88541d90d85770d63965dbcdc537c2cfbef5749..bfbb29a83f8e5ca9ab0d8eb27062349afff05df8 100644 --- a/src/videoag/object_management/OMConfigComponent.tsx +++ b/src/videoag/object_management/OMConfigComponent.tsx @@ -11,7 +11,6 @@ import type { } from "@/videoag/api/types"; import { useApi } from "@/videoag/api/ApiProvider"; import { showError, showErrorToast } from "@/videoag/error/ErrorDisplay"; -import { OMFieldEditor } from "@/videoag/form/TypeEditor"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; import { useReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; import { useDebounceUpdateData } from "@/videoag/miscellaneous/PromiseHelpers"; @@ -22,6 +21,7 @@ import StopNavigation from "@/videoag/miscellaneous/StopNavigation"; import { basePath } from "@/../basepath"; import { LockEditMode, useEditMode } from "./EditModeProvider"; +import { OMFieldEditor } from "./OMFieldEditor"; const markdownSupportingFields = [ "announcement.text", @@ -249,7 +249,10 @@ export function EmbeddedOMFieldComponent({ let formatted: any = "Value of unknown type: " + value; switch (field_type) { case "string": - formatted = <StylizedText markdown={allowMarkdown}>{value}</StylizedText>; + if (editMode && !value) + formatted = <p><i className="text-muted">{"<empty>"}</i></p> + else + formatted = <StylizedText markdown={allowMarkdown}>{value}</StylizedText>; break; case "datetime": formatted = datetimeToString(value); @@ -625,15 +628,6 @@ export function OMEdit({ )} {config?.fields !== undefined && ( <> - <div className="alert alert-warning"> - {/* TODO: remove after release */} - Achtung: Die alte Webseite kann kein Markdown rendern, nur HTML. - Wenn hier also Markdown verwendet wird, wird dies auf der alten - Seite nicht richtig gerendert. Während der Transition werden aber - auf der neuen Seite die folgenden HTML-Tags gerendert: a, b, br, p - und strong (Welche auf der alten Seite auch korrekt angezeigt - werden) - </div> <small className="text-muted"> Tipp: Viele Felder können im Edit-Mode auch direkt durch einen Doppelklick bearbeitet werden diff --git a/src/videoag/object_management/OMFieldEditor.tsx b/src/videoag/object_management/OMFieldEditor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..96ce0528277e7e8856aab8c7e2dd0a40817b666e --- /dev/null +++ b/src/videoag/object_management/OMFieldEditor.tsx @@ -0,0 +1,86 @@ +import React from "react"; + +import type { field_description } from "@/videoag/api/types"; +import { + EditorArgs, + StringEditor, + BooleanEditor, + IntEditor, + DatetimeEditor, + ChooserEditor, +} from "@/videoag/form/TypeEditor"; + +import { UserIdListEditor } from "./ObjectEditor"; +import ViewPermissionsEditor from "./ViewPermissionsEditor"; + +export function OMFieldEditor({ + description, + ...args +}: { + description: field_description; +} & EditorArgs) { + const { value } = args; + switch (description.type) { + case "string": + return ( + <StringEditor + {...args} + minLength={description.min_length} + maxLength={description.max_length} + regex={description.pattern ? new RegExp(description.pattern) : undefined} + regexInvalidMessage={ + description.pattern && + `Invalid value. Must match pattern: ${description.pattern}` + } + /> + ); + + case "boolean": + return <BooleanEditor {...args} />; + + case "int": + return ( + <IntEditor + {...args} + minValue={description.min_value} + maxValue={description.max_value} + /> + ); + + case "datetime": + return <DatetimeEditor {...args} mayBeNull={description.may_be_null} />; + + case "enum": + return ( + <ChooserEditor + {...args} + langKeyPrefix={`enum.${description.name!}`} + possibleValues={description.enums!} + /> + ); + + case "view_permissions": + return <ViewPermissionsEditor {...args} />; + + case "object_list": + // TODO other objects + if (description.object_type === "user") { + return <UserIdListEditor {...args} />; + } + return ( + <span> + Unknown field type {description.type}. {JSON.stringify(value)} + </span> + ); + + // TODO media_process + // TODO object + + default: + return ( + <span> + Unknown field type {description.type}. {JSON.stringify(value)} + </span> + ); + } +} diff --git a/src/videoag/object_management/ObjectEditor.tsx b/src/videoag/object_management/ObjectEditor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b441bc4129c0294b2a64a197b71501ce99dc91d9 --- /dev/null +++ b/src/videoag/object_management/ObjectEditor.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useRef, useState } from "react"; +import Dropdown from "react-bootstrap/Dropdown"; + +import type { GetUsersResponse, user, int } from "@/videoag/api/types"; +import { useApi } from "@/videoag/api/ApiProvider"; +import { EditorArgs } from "@/videoag/form/TypeEditor"; +import { showError } from "@/videoag/error/ErrorDisplay"; + +export function UserIdListEditor({ + value, + updateValue, + disabled, + autoFocus, + hideErrors, +}: EditorArgs) { + const api = useApi(); + const knownValue = useRef<int[]>(); + const [users, setUsers] = useState<GetUsersResponse>(); + const [search, setSearch] = useState<string>(""); + + useEffect(() => { + if (knownValue.current === value) return; + // Value was not changed by us, reset internal state + knownValue.current = value; + setUsers(undefined); + setSearch(""); + api.getUsers() + .then(setUsers) + .catch((err) => { + showError(err, "Unable to load users"); + }); + }, [api, knownValue, value]); + + useEffect(() => { + if (value !== undefined) return; + const newValue: int[] = []; + knownValue.current = newValue; + updateValue(newValue, true, true); + }); + + const selectedArray = value ?? []; + + let userList = selectedArray.length > 0 ? selectedArray.join(", ") : "None"; + if (users !== undefined && selectedArray.length > 0) { + userList = selectedArray + .map((id: int) => { + const user = users.users.find((u: user) => u.id === id); + if (user === undefined) return `#${id}`; + return `${user.display_name} (${user.handle}#${user.id})`; + }) + .join(", "); + } + if (userList.length > 100) userList = userList.slice(0, 100) + "..."; + + const searchTerm = search.toLowerCase(); + + const userElements = (users === undefined ? [] : users.users).flatMap((user: user) => { + if ( + !user.display_name.toLowerCase().includes(searchTerm) && + !user.full_name.toLowerCase().includes(searchTerm) && + !user.handle.toLowerCase().includes(searchTerm) + ) + return []; + const toggle = () => { + const index = selectedArray.indexOf(user.id); + let newArray = selectedArray.slice(); + if (index === -1) { + newArray.splice(newArray.length, 0, user.id); //Add user.id at end + } else { + newArray.splice(index, 1); //Remove user.id + } + knownValue.current = newArray; + updateValue(newArray, true, true); + }; + return [ + <tr key={user.id} className={selectedArray.includes(user.id) ? "table-primary" : ""}> + <td> + <input + type="checkbox" + checked={selectedArray.includes(user.id)} + onChange={toggle} + /> + </td> + <td onClick={toggle} className="pe-auto" style={{ cursor: "pointer" }}> + <span>{`${user.display_name} (${user.handle}#${user.id})`}</span> + </td> + </tr>, + ]; + }); + + return ( + <Dropdown> + <Dropdown.Toggle variant="primary" style={{ textWrap: "wrap" }} disabled={disabled}> + {userList} + </Dropdown.Toggle> + <Dropdown.Menu className="p-1"> + <div className="input-group mb-2 p-1"> + <span className="input-group-text"> + <i className="bi bi-search" /> + </span> + <input + type="text" + className="form-control" + placeholder="Username" + value={search} + autoFocus + onChange={(e) => setSearch(e.target.value)} + /> + <button + type="button" + className="btn btn-outline-secondary" + onClick={() => setSearch("")} + disabled={search === ""} + > + <i className="bi bi-x-circle" /> + </button> + </div> + {users === undefined ? ( + <div className="spinner-border" role="status" /> + ) : ( + <> + {userElements.length === 0 && ( + <div className="text-center">No users found</div> + )} + <div className="overflow-auto" style={{ maxHeight: "500px" }}> + <table className="table table-sm table-hover w-100"> + <thead> + <tr> + <th></th> + <th className="w-100"></th> + </tr> + </thead> + <tbody>{userElements}</tbody> + </table> + </div> + </> + )} + </Dropdown.Menu> + </Dropdown> + ); +} diff --git a/src/videoag/object_management/ViewPermissionsEditor.tsx b/src/videoag/object_management/ViewPermissionsEditor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5591bc8dd95c5400aeeee6e5471501fc4f5e4c55 --- /dev/null +++ b/src/videoag/object_management/ViewPermissionsEditor.tsx @@ -0,0 +1,292 @@ +import React, { useEffect, useRef, useState } from "react"; + +import type { view_permissions, int } from "@/videoag/api/types"; +import { EditorArgs } from "@/videoag/form/TypeEditor"; +import { useLanguage } from "@/videoag/localization/LanguageProvider"; +import { deepEquals } from "@/videoag/miscellaneous/Util"; + +function createListEditor<V>( + disabled: boolean, + list: V[], + defaultNewValue: V, + onNewValueList: (newList: V[]) => void, + elementFactory: (value: V, onNewValue: (newVal: V) => void) => React.ReactNode, +) { + return ( + <> + {list.map((value: V, index: int) => ( + <div key={index} className="d-block"> + {elementFactory(value, (newVal: V) => + onNewValueList( + list.map((subValue: V, subIndex: int) => + index === subIndex ? newVal : subValue, + ), + ), + )} + <button + type="button" + className={"btn btn-outline-danger m-1"} + onClick={(e) => { + const newList = list.slice(); + newList.splice(index, 1); + onNewValueList(newList); + }} + title="Delete" + > + <i className="bi bi-trash-fill" /> + </button> + </div> + ))} + <button + className="btn btn-secondary" + type="button" + title="Add entry" + onClick={(e) => { + onNewValueList(list.concat([defaultNewValue])); + }} + disabled={disabled} + > + + + </button> + </> + ); +} + +export default function ViewPermissionsEditor({ + value, + updateValue, + disabled, + autoFocus, + hideErrors, +}: EditorArgs) { + const permTypes = ["inherit", "public", "private", "authentication"]; + + // We need to store the current value locally since there is no order for the passwords and it would be really + // annoying if the order changes while typing + // This also gives the benefit of storing values for types which are currently not selected + type view_permissions_with_invalid = Omit<view_permissions, "passwords"> & { + passwords?: null | { [key: string]: string }; + }; + const { language } = useLanguage(); + const knownValue = useRef<view_permissions_with_invalid>(); + const [allDataValue, setAllDataValue] = useState<view_permissions_with_invalid>({ + type: "inherit", + }); + const [passwordsArray, setPasswordsArray] = useState<[string, string][]>([]); + + useEffect(() => { + if (deepEquals(value, knownValue.current)) return; + // Value was not changed by us, update internal state + const effectiveValue = value ?? { type: "inherit" }; + const passwordsMap = effectiveValue.passwords ?? {}; + setPasswordsArray( + Object.keys(passwordsMap).map((username: string) => [username, passwordsMap[username]]), + ); + setAllDataValue(effectiveValue); + knownValue.current = effectiveValue; + if (effectiveValue !== value) updateValue(effectiveValue, true, true); + }, [value, updateValue]); + + const updateData = (key: string, newData: any) => + setAllDataValue((prev: view_permissions_with_invalid | undefined) => { + let isValid = true; + + prev = prev ?? { type: "inherit" }; + let newAllData: view_permissions_with_invalid = { ...prev, [key]: newData }; + if (key === "passwords") { + setPasswordsArray(newData); + let newMap: { [key: string]: string } | null = {}; + for (let ele of newData) { + if (newMap[ele[0]] !== undefined) { + newMap = null; + break; + } + newMap[ele[0]] = ele[1]; + } + newAllData = { ...prev, passwords: newMap }; + } + + let newValue: view_permissions_with_invalid = { type: newAllData.type }; + if (newValue.type === "authentication") { + newValue.rwth_authentication = newAllData.rwth_authentication ?? false; + newValue.fsmpi_authentication = newAllData.fsmpi_authentication ?? false; + newValue.moodle_course_ids = newAllData.moodle_course_ids ?? []; + newValue.passwords = newAllData.passwords !== undefined ? newAllData.passwords : {}; + if (newValue.passwords === null) { + isValid = false; + } else if ( + !newValue.rwth_authentication && + !newValue.fsmpi_authentication && + newValue.moodle_course_ids.length === 0 && + Object.keys(newValue.passwords).length === 0 + ) { + isValid = false; + } + } + knownValue.current = newValue; + updateValue(newValue, isValid, true); + return newAllData; + }); + + let invalidMessage = undefined; + if ( + hideErrors !== true && + allDataValue?.type === "authentication" && + allDataValue.passwords !== null && + !( + allDataValue.rwth_authentication === true || + allDataValue.fsmpi_authentication === true || + (allDataValue.moodle_course_ids ?? []).length > 0 || + Object.keys(allDataValue.passwords ?? {}).length > 0 + ) + ) { + invalidMessage = ( + <div className="d-block invalid-feedback"> + Für Typ <i>Authentifizierung</i> muss mindestens eine Authentifizierungsmethode + vorhanden sein + </div> + ); + } + + let passwordsInvalidMessage; + if (hideErrors !== true && allDataValue?.type === "authentication") { + const passwordsMap: { [key: string]: null } = {}; + for (let ele of passwordsArray) { + if (passwordsMap[ele[0]] !== undefined) { + passwordsInvalidMessage = ( + <div className="d-block invalid-feedback"> + Doppelter Nutzername '{ele[0]}' + </div> + ); + break; + } + passwordsMap[ele[0]] = null; + } + } + + return ( + <div className="d-inline-grid"> + <select + className="w-auto form-select" + value={allDataValue?.type ?? "inherit"} + onChange={(e) => updateData("type", e.target.value)} + disabled={disabled} + autoFocus={autoFocus} + > + {permTypes.map((type) => ( + <option key={type} value={type}> + {language.get(`ui.view_permissions.type.${type}`)} + </option> + ))} + </select> + {allDataValue?.type === "authentication" && ( + <table className="w-100 table"> + <thead> + <tr> + <th scope="col" style={{ width: "30%" }} /> + <th scope="col" style={{ width: "70%" }} /> + </tr> + </thead> + <tbody> + <tr> + <td>RWTH authentication</td> + <td> + <input + type="checkbox" + className="form-check-input" + name="rwth_authentication" + defaultChecked={allDataValue?.rwth_authentication ?? false} + disabled={disabled} + onChange={(e) => + updateData("rwth_authentication", e.target.checked) + } + /> + </td> + </tr> + <tr> + <td>FSMPI authentication</td> + <td> + <input + type="checkbox" + className="form-check-input" + name="fsmpi_authentication" + defaultChecked={allDataValue?.fsmpi_authentication ?? false} + disabled={disabled} + onChange={(e) => + updateData("fsmpi_authentication", e.target.checked) + } + /> + </td> + </tr> + <tr> + <td>Moodle course ids</td> + <td> + {createListEditor<int>( + disabled ?? false, + allDataValue?.moodle_course_ids ?? [], + 0, + (newList: int[]) => updateData("moodle_course_ids", newList), + (value: int, onNewValue: (newVal: int) => void) => ( + <input + className="mb-1" + type="number" + value={value} + onChange={(e) => { + onNewValue(parseInt(e.target.value)); + }} + disabled={disabled ?? false} + /> + ), + )} + </td> + </tr> + <tr> + <td>Passwords</td> + <td> + <div className="d-inline-grid"> + <span> + {createListEditor<[string, string]>( + disabled ?? false, + passwordsArray, + ["", ""], + (newList: [string, string][]) => + updateData("passwords", newList), + ( + value: [string, string], + onNewValue: (newVal: [string, string]) => void, + ) => ( + <> + <input + className="me-1" + type="text" + placeholder="Username" + value={value[0]} + onChange={(e) => + onNewValue([e.target.value, value[1]]) + } + disabled={disabled ?? false} + /> + <input + type="text" + placeholder="Password" + value={value[1]} + onChange={(e) => + onNewValue([value[0], e.target.value]) + } + disabled={disabled ?? false} + /> + </> + ), + )} + </span> + {passwordsInvalidMessage} + </div> + </td> + </tr> + </tbody> + </table> + )} + {invalidMessage} + </div> + ); +}