diff --git a/Tokenization/webapp/app/app.css b/Tokenization/webapp/app/app.css index 3fd4a94f5..f74734019 100644 --- a/Tokenization/webapp/app/app.css +++ b/Tokenization/webapp/app/app.css @@ -12,15 +12,73 @@ * or submit itself to any jurisdiction. */ +:root { + --color-transparent: rgba(0,0,0,0) +} + +.wrap { + flex-wrap: wrap; +} + +.bg-transparent { + background-color: var(--color-transparent); +} + .justify-end { justify-content: flex-end; } +.self-center { + align-self: center; +} + .ml1 { margin-left: var(--space-xs); } .ml2 { margin-left: var(--space-s); } .ml3 { margin-left: var(--space-m); } .ml4 { margin-left: var(--space-l); } +.ml5 { margin-left: var(--space-xl)} +.mb2 { margin-bottom: var(--space-s)} + + +.no-border { + border: 0; +} + +.bra2 { + border: 2px solid black; +} + +.brb2 { + border-bottom: 2px solid black; +} + +.scale12 { + transform: scale(1.2); +} + +.scale15 { + transform: scale(1.5); +} .scale25 { transform: scale(2.5); } + +.menu-item-static { + /* This class is the same as menu-item from aliceo2 css framework but can be used wihouth hover and active actions*/ + text-decoration: none; + height: 2em; + line-height: 2em; + padding-left: 1em; + padding-right: 1em; + color: var(--color-gray-darker); + font-weight: 100; + margin: 0.25em; + border-radius: 0.25em; + display: block; + user-select: none; +} + +.static { + position: static; +} \ No newline at end of file diff --git a/Tokenization/webapp/app/assets/4_Color_Logo_CB.png b/Tokenization/webapp/app/assets/4_Color_Logo_CB.png new file mode 100644 index 000000000..7949e4c56 Binary files /dev/null and b/Tokenization/webapp/app/assets/4_Color_Logo_CB.png differ diff --git a/Tokenization/webapp/app/components/box.tsx b/Tokenization/webapp/app/components/box.tsx new file mode 100644 index 000000000..203c0a9be --- /dev/null +++ b/Tokenization/webapp/app/components/box.tsx @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React from 'react'; +import { Link } from 'react-router'; +import { IconContainer, IconExpandRight } from '~/ui/icon'; + +interface BoxInterface { + children: React.ReactNode; + link: string | null; +} + +interface PrimaryBoxInterface extends BoxInterface { + className_div1: string; + className_div2: string; +} +/** + * Box component - container which renders a content inside. + * + * TODO: On PC it will stand in grid layout on mobile will lay basing on display flex with wrap + * For now it uses grid only. + * + * @param {object} props - Component props. + * @param {React.ReactNode} props.children - Content rendered inside the box. + * @param {string|null} props.link - Passing this prop to the component enables rendering of the top bar + * with an icon that navigates the user to the corresponding details page. + * @param {string} props.className_div1 - Additional classes applied to the outer container. + * @param {string} props.className_div2 - Additional classes applied to the top link row container. + */ +export function Box({ children, link, className_div1, className_div2 }: PrimaryBoxInterface) { + return ( +
+ {link &&
+
+ + + + + +
+
+ } + {children} +
+ ); +} + +/** + * Box 1_2 component - renders box with padding suited for grid with 1 row and 2 columns. + * + * @param {object} props - Component props. + * @param {React.ReactNode} props.children - Content rendered inside the box. + * @param {string|null} props.link - Passing this prop to the component enables rendering of the top bar + * with an icon that navigates the user to the corresponding details page. + */ +export function Box1_2 ({ children, link }: BoxInterface) { + return ( + +
+
+ {children} +
+
+
+ ); +} + +/** + * Box 1_2 component - renders box with padding suited for grid with 1 row and 1 column. + * + * @param {object} props - Component props. + * @param {React.ReactNode} props.children - Content rendered inside the box. + * @param {string|null} props.link - Passing this prop to the component enables rendering of the top bar + * with an icon that navigates the user to the corresponding details page. + */ +export function Box1_1 ({ children, link }: BoxInterface) { + return ( + +
+
+ {children} +
+
+ ); +} diff --git a/Tokenization/webapp/app/components/form/form-buttons.tsx b/Tokenization/webapp/app/components/form/form-buttons.tsx new file mode 100644 index 000000000..358e0b364 --- /dev/null +++ b/Tokenization/webapp/app/components/form/form-buttons.tsx @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import type { ButtonInterface } from '../window/window'; + +/** + * Generic form button wrapper. + * + * Renders a + ); +} + +/** + * Submit button with primary styling. + * + * @param {object} props - Component props. + * @param {() => void} [props.action] - Click handler; will be wrapped with preventDefault() behaviour. + * @param {string} [props.className] - Additional classes to modify appearance (recommended for color changes). + */ +export function SubmitButton({ action, className }: ButtonInterface) { + return ( + + Submit + + ); +} + +/** + * Reset button with danger styling. + * + * @param {object} props - Component props. + * @param {() => void} [props.action] - Click handler; will be wrapped with preventDefault() behaviour. + * @param {string} [props.className] - Additional classes to modify appearance (recommended for color changes). + */ +export function ResetButton({ action, className }: ButtonInterface) { + return ( + + Reset + + ); +} diff --git a/Tokenization/webapp/app/components/form/form-input.tsx b/Tokenization/webapp/app/components/form/form-input.tsx new file mode 100644 index 000000000..67d65b66c --- /dev/null +++ b/Tokenization/webapp/app/components/form/form-input.tsx @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React, { type PropsWithChildren } from 'react'; +import { type FormInputInterface } from './form.d'; + +/** + * FormInput + * + * Generic input wrapper that normalizes change handling for string and number values. + * + * @template T - input value type, either string or number (default: string). + * @param {object} props - Component props. + * @param {T} props.value - Current input value. + * @param {(v: T) => void} props.setValue - Setter for the value; called with parsed value on change. + * @param {string} [props.labelText] - Optional label text displayed above the input. + * @param {React.HTMLAttributes} [props.containerProps] - Props spread onto the outer container element. + * @param {React.LabelHTMLAttributes} [props.labelProps] - Props spread onto the label element. + * @param {React.InputHTMLAttributes} [props.inputProps] - Props spread onto the input element. + * +*/ +function FormInput({ + value, + setValue, + labelText, + name, + children, +}: PropsWithChildren>) { + + // If setValue and value are provided than input is controlled + const handleChange = (e: React.ChangeEvent) => { + const { target } = e; + let newVal: T; + if (typeof value === 'number') { + const parsed = parseFloat(target.value); + newVal = (isNaN(parsed) ? 0 : parsed) as T; + } else { + newVal = target.value as T; + } + setValue?.(newVal); + }; + + const childInput = React.Children.toArray(children)[0]; + const input = React.cloneElement(childInput as React.ReactElement>, { + id: name, + value: value as unknown as string, + onChange: handleChange, + }); + + return ( +
+ {labelText && ( + + )} + {input} +
+ ); +} + +/** + * FormInputNumber + * + * Number input specialization of FormInput. + * + * @param {object} props - component props + * @param {number} props.value - current numeric value + * @param {(v: number) => void} props.setValue - setter for the numeric value + * @param {string} [props.labelText] - optional label text + * @param {string} props.name - input name attribute + * + * notes: + * - step is set to 1 and min to 0 in the input element + * - if value and setValue are provided than input is controlled + */ +export function FormInputNumber({ value, setValue, labelText, name }: FormInputInterface) { + return + + ; +} + +export function FormInputDatetime({ value, setValue, labelText, name }: FormInputInterface) { + return + + ; +} \ No newline at end of file diff --git a/Tokenization/webapp/app/components/form/form-select.tsx b/Tokenization/webapp/app/components/form/form-select.tsx new file mode 100644 index 000000000..da5c8b9ce --- /dev/null +++ b/Tokenization/webapp/app/components/form/form-select.tsx @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +import type { OptionType } from '~/utils/types'; +import { type SelectInterface } from './form.d'; +import { FormSelectBase, SelectFrame, SelectFrameMulti } from './select-helper'; + +/** + * FormSelect + * + * Single-select wrapper that maps value/setValue to selected option and selection handler. + * + * @template T + * @param {object} props - component props + * @param {string} props.id - unique id (from SelectInterface) + * @param {import('~/utils/types').OptionType[]} props.options - array of options to choose from + * @param {T} props.value - currently selected raw value (string|number) + * @param {React.Dispatch>} props.setValue - setter to update the raw value when selection changes + * @param {string} [props.placeholder] - placeholder text + * @param {string|null} [props.label] - optional label shown above select + * + * Behaviour: + * - Finds the Option matching `value` and passes that to FormSelectBase as `selected`. + * - Provides handleSelect(val: T) that updates `setValue(val)`. + */ +export function FormSelect(props: SelectInterface) { + const { value, setValue, options } = { ...props }; + const selected = options.find((o) => o.value === value) ?? null; + const handleSelect = (val: T) => { + setValue(val); + }; + + return ( + ? U : T) => void} + render={SelectFrame} + /> + ); +} + +/** + * FormSelectMulti + * + * Multi-select wrapper that expects value to be an array and provides select/deselect handlers. + * + * @template T + * @param {object} props - component props + * @param {string} props.id - unique id (from SelectInterface) + * @param {import('~/utils/types').OptionType[]} props.options - array of available options + * @param {T[]} props.value - array of selected raw values + * @param {React.Dispatch>} props.setValue - setter to update the array of selected values + * @param {string} [props.placeholder] - placeholder text + * @param {string|null} [props.label] - optional label shown above select + * + * Behaviour: + * - Computes `selected` as list of Option entries whose value is included in `value`. + * - handleSelect adds an item to the value array; handleDeselect removes it. + */ +export function FormSelectMulti(props: SelectInterface) { + const { value, setValue, options } = { ...props }; + // Now elements in `selected` follow the order of adding + const selected = (value as T[]).map(v => options.find(o => o.value === v)).filter(Boolean) || []; + + const handleSelect = (val: T) => { + setValue((prev) => [...prev, val]); + }; + + const handleDeselect = (val: T) => { + setValue((prev) => prev.filter(v => v !== val)); + }; + + return ( + + ); +} + +/** + * FormSelectMultiOrdering + * + * Multi-select wrapper that filters out options that are already selected in opposite order. + * + * @template T + * @param {object} props - component props + * @param {string} props.id - unique id (from SelectInterface) + * @param {import('~/utils/types').OptionType[]} props.options - array of available options + * @param {T[]} props.value - array of selected raw values + * @param {React.Dispatch>} props.setValue - setter to update the array of selected values + * @param {string} [props.placeholder] - placeholder text + * @param {string|null} [props.label] - optional label shown above select + * + * Behaviour: + * - Computes `selected` as list of Option entries whose value is included in `value`. + * - handleSelect adds an item to the value array; handleDeselect removes it. + * - Filters out options that are already selected in opposite order (e.g., if 'id' is selected, '-id' is removed from options). + */ +export function FormSelectMultiOrdering(props: SelectInterface) { + const { value, setValue, options } = { ...props }; + let optionsFiltered = options; + + // Typescipt safety check + if (Array.isArray(value)) { + for (const val of value) { + const valStr = String(val); + // There is django ordering convention where negative value means opposite order + if (valStr.startsWith('-')) { + const actualVal = valStr.substring(1); + optionsFiltered = optionsFiltered.filter(opt => String(opt.value) !== actualVal); + } else { + optionsFiltered = optionsFiltered.filter(opt => String(opt.value) !== `-${ valStr}`); + } + } + } + + return ( + + ); +} diff --git a/Tokenization/webapp/app/components/form/form.d.ts b/Tokenization/webapp/app/components/form/form.d.ts new file mode 100644 index 000000000..db8a4e99f --- /dev/null +++ b/Tokenization/webapp/app/components/form/form.d.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import type React from 'react'; +import { type SetStateAction } from 'react'; +import type { OptionType as Option, DialogPropsBase as DPB } from '../../utils/types'; + +export interface FormInputInterface { + value?: T; + setValue?: React.Dispatch>; + labelText?: string; + name: string; +} + +export interface SelectInterface ? U : T> { + id: string; + options: Option[]; + placeholder?: string; + label: string | null; + value: T; + setValue: React.Dispatch>; + selected?: Option | Option[] | null; + handleSelect?: (value: V) => void; + handleDeselect?: (value: V) => void; + takeSelectedToOption?: boolean; + render?: React.ElementType; +} + +export interface SelectLabelProps extends DPB { + selected: Option | Option[] | null; + placeholder: string; + handleDeselect?: (value: T) => void; +} + +export interface SelectOptionsProps extends DPB { + options: Option[]; + selected?: Option | Option[] | null; + takeSelectedToOption?: boolean; + handleSelect?: (value: T) => void; + +} diff --git a/Tokenization/webapp/app/components/form/form.tsx b/Tokenization/webapp/app/components/form/form.tsx new file mode 100644 index 000000000..131b327a0 --- /dev/null +++ b/Tokenization/webapp/app/components/form/form.tsx @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React from 'react'; +import type { useFetcher } from 'react-router'; + +interface FormInterface extends React.HTMLAttributes { + action: string; + fetcher: ReturnType; + submitRef: React.RefObject; +} + +/** + * Form + * + * Lightweight wrapper around a react-router's element. + * + * @param {object} props - component props + * @param {React.ReactNode} props.children - contents rendered inside the form + * @param {string} [props.className] - CSS classes applied to the outer wrapper
+ * @param {string} [props.id] - id applied to the outer wrapper
+ */ +export const Form = ({ children, className, id, action, fetcher, submitRef }: FormInterface) => { + const _className = className ? ` ${className}` : ''; + + return ( +
+ + {children} + + +
+ ); +}; diff --git a/Tokenization/webapp/app/components/form/multi-select-helper.tsx b/Tokenization/webapp/app/components/form/multi-select-helper.tsx new file mode 100644 index 000000000..cf017c865 --- /dev/null +++ b/Tokenization/webapp/app/components/form/multi-select-helper.tsx @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { type OptionType as Option } from '~/utils/types'; +import { type SelectInterface } from './form.d'; +import { IconContainer, IconX } from '~/ui/icon'; + +interface SelectedOptionListProps { + handleDeselect: SelectInterface['handleDeselect']; + value?: SelectInterface['value']; + selected?: SelectInterface['selected']; + label?: SelectInterface['label']; +} + +/** + * SelectedOption + * + * List item representing a selected option with a remove button - used to deselect the option. + * + * @param {object} props - component props + * @param {(string|number)|undefined} props.value - option value (string|number) + * @param {string|undefined} props.label - option label displayed + * @param {(function(string|number):void)|undefined} props.handleDeselect - optional callback invoked with the option value when remove clicked + * + * Notes: + * - The internal button stopPropagation() so clicks on the remove icon don't toggle the dropdown. + */ +function SelectedOption({ value, label, handleDeselect }: SelectedOptionListProps) { + const _handleDeselect = (e: React.MouseEvent, value: SelectInterface['value']) => { + handleDeselect?.(value as string | number); + e.stopPropagation(); + }; + + return ( +
  • + {label} + +
  • + ); +} + +/** + * SelectedList + * + * Renders list of currently selected options with remove buttons. + * + * @param {object} props - component props + * @param {import('~/utils/types').OptionType[]|undefined} props.selected - selected options (expected Option[]) + * @param {(function(string|number):void)|undefined} props.handleDeselect - optional deselect callback + * + * Notes: + * - `selected` is treated as Option[] for rendering; the component expects Option objects from ~/utils/types. + */ +export function SelectedList({ selected, handleDeselect }: SelectedOptionListProps) { + return ( +
      + { + (selected as Option[]) + .map((s: Option) => + , + ) + } +
    + ); +} diff --git a/Tokenization/webapp/app/components/form/select-group.tsx b/Tokenization/webapp/app/components/form/select-group.tsx new file mode 100644 index 000000000..e771f8423 --- /dev/null +++ b/Tokenization/webapp/app/components/form/select-group.tsx @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React, { type PropsWithChildren } from 'react'; +import type { SelectInterface } from './form.d'; +import type { OptionType } from '~/utils/types'; + +import { FormSelect } from './form-select'; +import { checkIsComponentOfType } from '~/utils/component-type-checker'; + +/** + * Helper collectSelectsInfo + * + * Inspects children to collect FormSelect components and their options and values. + * + * @param {React.ReactNode} children - child nodes which may include FormSelect components + * + * @returns {object} - object containing: + * - selects: array of FormSelect components found among children + * - selectsLen: number of FormSelect components found + * - optionsList: array of options arrays for each FormSelect + * - values: array of selected values for each FormSelect + */ +function collectSelectsInfo(children: React.ReactNode) { + const arrChildren = React.Children.toArray(children); + const selects = arrChildren.filter((component) => checkIsComponentOfType(component, FormSelect)); + const optionsList = selects.map((select) => React.isValidElement(select) ? (select.props as SelectInterface).options : null); + const values = selects.map((select) => React.isValidElement(select) ? (select.props as SelectInterface).value : null); + const selectsLen = selects.length; + + return { + selects, + selectsLen, + optionsList, + values, + }; +} + +type valuesType = (string | number | (string | number)[] | null)[]; + +/** + * FilterSelectedFromOptions + * + * Clones select components with filtered options to remove already selected values from other selects. + * + * @param {React.ReactNode[]} selects - array of select components + * @param {number} selectsLen - length of selects array + * @param {(OptionType[] | null)[]} optionsList - array of options arrays for each select + * @param {valuesType} values - array of selected values for each select + * + * @returns {React.ReactNode[]} - array of cloned select components with filtered options + */ +function filterSelectedFromOptions(selects: React.ReactNode[], selectsLen: number, optionsList: (OptionType[] | null)[], values: valuesType) { + const returnChildren = []; + + for (let i = 0; i < selectsLen; i++) { + let select = selects[i]; + let options = optionsList[i]; + + // Get all values selected in other selects + const differentSelectValues = values.filter((_, idx) => idx != i); + + if (options !== null) { + options = options.filter((opt) => !differentSelectValues.includes(opt.value)); + select = React.cloneElement(select as React.ReactElement, { + options: options, + }); + } + returnChildren.push(select); + } + + return [...returnChildren]; +} + +/** + * SelectGroup + * + * Inspects children and deduplicates options across FormSelect children by cloning them. + * + * @param {object} props - component props + * @param {React.ReactNode} props.children - child nodes which should only include FormSelect components; + * SelectGroup will detect those and clone them with filtered options + * + * Behaviour notes: + * - Looks for direct children of type FormSelect. + * - Builds list of values used by other selects and removes them from each select's options to avoid duplicates. + * - Clones and returns modified select children; non-select children are not displayed so they shouldn't be used. + */ +export function SelectGroup({ children }: PropsWithChildren) { + const { selects, selectsLen, optionsList, values } = collectSelectsInfo(children); + return filterSelectedFromOptions(selects, selectsLen, optionsList, values); +} diff --git a/Tokenization/webapp/app/components/form/select-helper.tsx b/Tokenization/webapp/app/components/form/select-helper.tsx new file mode 100644 index 000000000..021722434 --- /dev/null +++ b/Tokenization/webapp/app/components/form/select-helper.tsx @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { type OptionType as Option } from '~/utils/types'; +import { type SelectLabelProps, type SelectOptionsProps, type SelectInterface } from './form.d'; +import { SelectedList } from './multi-select-helper'; + +/** + * SelectFrame + * + * Renders the collapsed select frame for single-select mode. + * + * @template T + * @param {object} props - component props + * @param {boolean} props.open - whether the dropdown is open + * @param {React.Dispatch>} props.setOpen - setter (useState dispatcher) to toggle open state + * @param {import('~/utils/types').OptionType | import('~/utils/types').OptionType[] | null} props.selected - + * currently selected option (Option) or null / array for multi + * @param {string} props.placeholder - placeholder text shown when nothing selected + * @param {(value: T) => void} [props.handleDeselect] - optional deselect handler (not used in single-frame but available from the shared type) + * + * Notes: + * - Props come from SelectLabelProps. + */ +export function SelectFrame( + props: SelectLabelProps & { setOpen: React.Dispatch> }, +) { + const { open, setOpen, selected, placeholder } = props; + const _selected: Option | null = Array.isArray(selected) ? (selected.length > 0 ? selected[0] : null) : selected; + + return (
    setOpen((prev) => !prev)} + className="flex-row border p2 justify-between br2 bg-white bra2" + > + + {_selected ? _selected.label : {placeholder}} + + {open ? '▴' : '▾'} +
    + ); +} + +/** + * SelectFrameMulti + * + * Renders the collapsed select frame for multi-select mode (shows selected list). + * + * @template T + * @param {object} props - component props + * @param {boolean} props.open - whether the dropdown is open + * @param {React.Dispatch>} props.setOpen - setter (useState dispatcher) to toggle open state + * @param {import('~/utils/types').OptionType | import('~/utils/types').OptionType[] | null} props.selected - selected option(s) + * @param {string} props.placeholder - placeholder text shown when no selection + * @param {(value: T) => void} [props.handleDeselect] - callback invoked when user removes a selected item + * + * Notes: + * - Uses SelectedList when selected is an array. - which should be the case for multi-select. + */ +export function SelectFrameMulti( + props: SelectLabelProps & { setOpen: React.Dispatch> }, +) { + const { open, setOpen, selected, placeholder, handleDeselect } = props; + const _selected: Option[] | null = Array.isArray(selected) ? selected : (selected ? [selected] : null); + + return (
    setOpen((prev) => !prev)} + className="flex-row border p2 justify-between br2 bg-white bra2" + > + + {_selected && + _selected.length > 0 ? + void} /> : + {placeholder} + } + + {open ? '▴' : '▾'} +
    + ); +} + +/** + * SelectOptions + * + * Renders dropdown list of options and calls handleSelect on click. + * + * @template T + * @param {object} props - component props + * @param {boolean} props.open - whether the dropdown is open + * @param {React.Dispatch>} props.setOpen - setter (useState dispatcher) to change open state + * @param {import('~/utils/types').OptionType[]} props.options - available options to render + * @param {import('~/utils/types').OptionType | import('~/utils/types').OptionType[] | null} [props.selected] - currently selected option(s); + * used to hide already-selected items when takeSelectedToOption is false + * @param {boolean} [props.takeSelectedToOption=true] - when false, selected options are removed from the options list + * @param {(value: T) => void} [props.handleSelect] - callback invoked with the option value when an option is clicked + * + * Notes: + * - Props come from SelectOptionsProps. + */ +export function SelectOptions( + { takeSelectedToOption = true, ...rest }: + SelectOptionsProps & { setOpen: React.Dispatch> }, +) { + const { open, setOpen, handleSelect, options, selected } = rest; + const _selected = Array.isArray(selected) ? selected : [selected]; + const visibleOptions = takeSelectedToOption ? options : options.filter((opt) => !(_selected.includes(opt))); + + const _handleSelect = (val: T) => { + handleSelect?.(val); + setOpen(false); + }; + + return ( + <> + {open && ( +
      + {visibleOptions.length > 0 ? ( + visibleOptions.map((opt) => ( +
    • _handleSelect(opt.value as T)} + className="f4 menu-item m0" + > + {opt.label} +
    • + )) + ) : ( +
    • No options available
    • + )} +
    + )} + + ); +} + +/** + * FormSelectBase + * + * Base logic used by single and multi select components (frame + options + outside-click handling). + * + * @template T,V + * @param {object} props - component props + * @param {string} props.id - unique id for the select root element + * @param {import('~/utils/types').OptionType[]} props.options - list of options shown in the dropdown + * @param {string} [props.placeholder] - placeholder text shown when nothing selected + * @param {string|null} [props.label] - optional label element displayed above the select frame + * @param {import('~/utils/types').OptionType | import('~/utils/types').OptionType[] | null} [props.selected] - + * currently selected option(s) passed to the frame renderer + * @param {(value: V) => void} [props.handleSelect] - callback invoked when an option is selected + * @param {(value: V) => void} [props.handleDeselect] - callback invoked when an option is deselected (used in multi-select) + * @param {boolean} [props.takeSelectedToOption] - whether selected items remain visible in the options list + * @param {React.ElementType} [props.render] - renderer for the select frame - + * FormSelect uses SelectFrame, FormSelectMulti uses SelectFrameMulti + * + * Notes: + * - This component handles outside-click closing and delegates frame and options rendering. + */ +export function FormSelectBase ? U : T>({ + id, + options = [], + placeholder = 'Choose an option', + label, + selected, + handleSelect, + handleDeselect, + takeSelectedToOption, + render, + value, +}: SelectInterface) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + const handleOutsideClick = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, []); + + const onClickExpand = () => { + setOpen((prev) => !prev); + }; + + const selectFrame = render ? + React.createElement(render, { open, setOpen, selected, placeholder, handleDeselect, takeSelectedToOption }) + : null; + + return ( +
    + {label && {label}} + {selectFrame} + ? U : T) => void} + selected={selected} + takeSelectedToOption={takeSelectedToOption} + /> + +
    + ); +} diff --git a/Tokenization/webapp/app/components/hooks/useForm.tsx b/Tokenization/webapp/app/components/hooks/useForm.tsx new file mode 100644 index 000000000..a123d3c67 --- /dev/null +++ b/Tokenization/webapp/app/components/hooks/useForm.tsx @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { useCallback, useRef } from 'react'; +import { useFetcher } from 'react-router'; + +/** + * Hook useForm + * + * Provides a fetcher form along with a submit function and a ref to the form element. It cooperates with + *
    component to allow programmatic form submission. + * + * @returns {fetcher: ReturnType, submit: () => void, ref: React.RefObject} + * + */ +export default function useForm() { + const fetcher = useFetcher(); + const ref = useRef(null); + const submit = useCallback(() => { + ref.current?.click(); + }, [ref]); + + return { fetcher, submit, ref }; +} diff --git a/Tokenization/webapp/app/components/hooks/useWindowLogic.tsx b/Tokenization/webapp/app/components/hooks/useWindowLogic.tsx new file mode 100644 index 000000000..c81737bd6 --- /dev/null +++ b/Tokenization/webapp/app/components/hooks/useWindowLogic.tsx @@ -0,0 +1,219 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React, { type PropsWithChildren } from 'react'; +import { type WindowInterface } from '../window/window.d'; + +import { type ButtonInterface, type WindowElementsWithAction } from '../window/window.d'; +import { WindowTitle, WindowContent, WindowButtonAccept, WindowButtonCancel, WindowCloseIcon } from '../window/window-objects'; +import { checkIsComponentOfType } from '~/utils/component-type-checker'; + +/** + * GetWindowChildrenAndActions + * + * Non-hook helper. Scans React children and extracts known Window subcomponents + * (WindowTitle, WindowContent, WindowButtonCancel, WindowButtonAccept, WindowCloseIcon). + * + * @param {object} props + * @param {React.PropsWithChildren} props.children - children passed to the Window component + * @returns {WindowElementsWithAction} Object with: + * - title: React.ReactNode | undefined (the WindowTitle child if present) + * - content: React.ReactNode | undefined (the WindowContent child if present) + * - buttonCancel: React.ReactNode | undefined (the WindowButtonCancel child if present) + * - buttonAccept: React.ReactNode | undefined (the WindowButtonAccept child if present) + * - closeIcon: React.ReactNode | undefined (the WindowCloseIcon child if present) + * - acceptAction: (() => void) | undefined (action extracted from the accept button props, if any) + * + */ +function getWindowChildrenAndActions({ children }: PropsWithChildren) { + const arrChildren = React.Children.toArray(children); + + const title = arrChildren.find( + (child) => checkIsComponentOfType(child, WindowTitle), + ); + const content = arrChildren.find( + (child) => checkIsComponentOfType(child, WindowContent), + ); + const buttonCancel = arrChildren.find( + (child) => checkIsComponentOfType(child, WindowButtonCancel), + ); + const buttonAccept = arrChildren.find( + (child) => checkIsComponentOfType(child, WindowButtonAccept), + ); + const closeIcon = arrChildren.find( + (child) => checkIsComponentOfType(child, WindowCloseIcon), + ); + + const acceptAction = React.isValidElement(buttonAccept) ? + (buttonAccept.props as ButtonInterface).action : + undefined; + + return { title, content, buttonCancel, buttonAccept, closeIcon, acceptAction } as WindowElementsWithAction; +} + +/** + * ProvidePropsForWindowChildren + * + * Non-hook helper. Clones provided button/close icon elements and injects + * runtime action handlers (cancelAction / wrapped acceptAction). + * + * @param {WindowElementsWithAction} params - object containing children and actions + * @param {React.ReactNode} params.closeIcon - close icon element (may be undefined) + * @param {React.ReactNode} params.buttonCancel - cancel button element (may be undefined) + * @param {React.ReactNode} params.buttonAccept - accept button element (may be undefined) + * @param {() => void} params.cancelAction - function to call to cancel/close the window + * @param {() => void | undefined} params.acceptAction - original accept action (optional); will be wrapped so it also triggers cancelAction + * @returns {{buttonCancel: React.ReactNode, buttonAccept: React.ReactNode, closeIcon: React.ReactNode}} + * + * Behaviour: + * - If a provided element is a valid React element, it will be cloned with an injected + * `action` prop bound to cancelAction or the wrapped accept action. + * - Otherwise returns empty fragments for missing elements. + */ +function providePropsForWindowChildren({ closeIcon, buttonCancel, buttonAccept, cancelAction, acceptAction }: WindowElementsWithAction) { + const _acceptAction = () => { + acceptAction?.(); + cancelAction(); + }; + + const _buttonCancel = React.isValidElement(buttonCancel) ? + React.cloneElement(buttonCancel as React.ReactElement, { action: cancelAction }) : + <>; + + const _closeIcon = React.isValidElement(closeIcon) ? + React.cloneElement(closeIcon as React.ReactElement, { action: cancelAction }) : + <>; + + const _buttonAccept = React.isValidElement(buttonAccept) ? + React.cloneElement(buttonAccept as React.ReactElement, { action: _acceptAction }) : + <>; + + return { + buttonCancel: _buttonCancel, + buttonAccept: _buttonAccept, + closeIcon: _closeIcon, + }; + +} + +/** + * UseWindowLogic + * + * Hook. Manages simple timing/close behaviour for window-like components. + * + * @param {WindowInterface} params + * @param {boolean} params.open - whether the window is open + * @param {(open: boolean) => void} params.setOpen - state setter (React.Dispatch) used to open/close the window + * @param {() => void | undefined} params.onClose - optional callback invoked when window is closed + * @param {number | null} [params.timeout=null] - optional auto-close timeout in milliseconds + * @returns {{ cancelAction: () => void }} + * + * Behaviour / side-effects: + * - When `open` is true and `timeout` is a number, starts a timer that calls setOpen(false) and onClose() after timeout. + * - Cleans up the timer on unmount or when dependencies change. + * - cancelAction: synchronous function that closes the window (setOpen(false)), calls onClose(), and clears the timer. + * + * Notes: + * - This is a React hook (uses useEffect, useRef) and must be called following hooks rules. + */ +function useWindowLogic({ open, setOpen, onClose, timeout = null }: WindowInterface) { + const timerRef = React.useRef(null); + + // Timeout + React.useEffect(() => { + if (open && timeout) { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + setOpen(false); + onClose?.(); + }, timeout); + } + + // Unmounting timer + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [open, timeout, setOpen, onClose]); + + const cancelAction = () => { + setOpen(false); + onClose?.(); + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + + return { cancelAction }; +} + +/** + * UseFullWindowLogic + * + * Hook. Combines child extraction and runtime wiring for window components. + * + * @param {WindowInterface} params + * @param {React.ReactNode} params.children - children of the window component (may include WindowTitle, WindowContent, buttons, close icon) + * @param {boolean} params.open - whether the window is open + * @param {React.Dispatch>} params.setOpen - dispatcher to toggle window open state + * @param {() => void | undefined} [params.onClose] - callback invoked when window is closed + * @param {number | null} [params.timeout=null] - optional auto-close timeout in ms + * @returns {{ + * visibility: string, + * ui_elements: { + * title: React.ReactNode | undefined, + * content: React.ReactNode | undefined, + * closeIcon: React.ReactNode, + * buttonAccept: React.ReactNode, + * buttonCancel: React.ReactNode + * } + * }} + * + * Behaviour: + * - Calls useWindowLogic to obtain cancelAction and timer behaviour. + * - Extracts window subcomponents and any accept action using getWindowChildrenAndActions. + * - Produces cloned button/closeIcon nodes wired to cancelAction / wrapped accept action via providePropsForWindowChildren. + * - Computes `visibility` string: 'd-block' when open, 'd-none' when closed. + * + * Notes: + * - This is a hook and must follow React hooks rules. + * - Return structure is intended for direct consumption by Window-like components to render content and controls. + */ +export function useFullWindowLogic({ children, open, setOpen, onClose, timeout = null }: WindowInterface) { + const { cancelAction } = useWindowLogic({ open, setOpen, onClose, timeout }); + const winElemsAndActions: WindowElementsWithAction = getWindowChildrenAndActions({ children }); + const { closeIcon, buttonAccept, buttonCancel } = providePropsForWindowChildren({ ...winElemsAndActions, cancelAction }); + const { title, content } = winElemsAndActions; + + const visibility = open ? + 'd-block' : + 'd-none'; + + return { + visibility: visibility, + ui_elements: { + title, + content, + closeIcon, + buttonAccept, + buttonCancel, + }, + }; +} diff --git a/Tokenization/webapp/app/components/tokens/action-block.tsx b/Tokenization/webapp/app/components/tokens/action-block.tsx index 12185e4fa..7461f8f55 100644 --- a/Tokenization/webapp/app/components/tokens/action-block.tsx +++ b/Tokenization/webapp/app/components/tokens/action-block.tsx @@ -12,40 +12,54 @@ * or submit itself to any jurisdiction. */ -import React from 'react'; import { IconDelete } from '~/ui/icon'; -interface DeleteDialogState { - isOpen: boolean; - tokenId: string; -} - interface ActionBlockProps { - tokenId: string; - setActionDeleteWindow: React.Dispatch>; + onClick: () => void; + title?: string; } /** - * Action block component that provides token actions such as delete + * ActionBlock + * + * Small UI block that renders action button with specified click handler and title. + * + * @param {object} props - component props + * @param {() => void} props.onClick - click handler invoked when the action button is pressed + * @param {string} props.title - optional title for the action button */ -export default function ActionBlock({ tokenId, setActionDeleteWindow }: ActionBlockProps) { - const handleDelete = () => { - setActionDeleteWindow({ - isOpen: true, - tokenId: tokenId, - }); - }; - +export function ActionBlockBase({ onClick, title }: ActionBlockProps) { return (
    ); } + +/** + * ActionBlockBulk + * + * UI block that renders action button for bulk token revocation. + * + * @param {object} props - component props + * @param {() => void} props.onClick - click handler invoked when the action button is pressed + */ +export function ActionBlockBulk({ onClick }: ActionBlockProps) { + return (); +} + +/** + * ActionBlockSolo + * + * @param {object} props - component props + * @param {() => void} props.onClick - click handler invoked when the action button is pressed + */ +export function ActionBlockSolo({ onClick }: ActionBlockProps) { + return (); +} diff --git a/Tokenization/webapp/app/components/tokens/token-filters.tsx b/Tokenization/webapp/app/components/tokens/token-filters.tsx new file mode 100644 index 000000000..1750cb30f --- /dev/null +++ b/Tokenization/webapp/app/components/tokens/token-filters.tsx @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { useEffect } from 'react'; + +import { setStorageItem } from '~/utils/storage'; + +import { FormSelectMulti, FormSelectMultiOrdering } from '../form/form-select'; +import { FormInputDatetime } from '../form/form-input'; +import { useTokenFilters } from '~/hooks/tokens/token-filters'; +import { FlexGrowWrapper, FlexGrowWrapperElement } from '~/ui/flex'; + +const _applyFilters = ({ services, ...filterStates }: any) => { + // eslint-disable-next-line no-console + console.log('Applying filters with state:', filterStates); + setStorageItem('TKN_token-filters', filterStates); +}; + +/** + * TokenFilters + * + * Renders token filters form and manages its state via useTokenFilters hook. + * + * Notes: + * - Non-reusable component specific logic is kept inside this component. + * + * @returns {JSX.Element} - rendered component + */ +export function TokenFilters() { + // Deleting stored filters on component un-mount + useEffect(() => () => { + setStorageItem('TKN_token-filters', {}); + }, []); + + const { state, actions } = useTokenFilters(); + const { + services, + firstSelectedService, + secondSelectedService, + httpMethods, + expirationDateMin, + expirationDateMax, + issueDateMin, + issueDateMax, + ordering, + } = state; + + const { + setServices, + setFirstSelectedService, + setSecondSelectedService, + setHttpMethods, + setExpirationDateMin, + setExpirationDateMax, + setIssueDateMin, + setIssueDateMax, + setOrdering, + clearAllFilters, + } = actions; + + const columns = [ + 'ID', 'Issue Date', 'Expiration Date', + ]; + + const orderingOptions = []; + for (const col of columns) { + orderingOptions.push({ value: col.toLowerCase().replace(/\s+/g, '_'), label: col }); + orderingOptions.push({ value: `-${col.toLowerCase().replace(/\s+/g, '_')}`, label: `${col} (desc)` }); + } + + useEffect(() => { + // Load services from API mock + setTimeout(() => { + setServices([ + { value: 'service1', label: 'Service 1' }, + { value: 'service2', label: 'Service 2' }, + { value: 'service3', label: 'Service 3' }, + { value: 'service4', label: 'Service 4' }, + ]); + }, 500); + + }, [setServices]); + + const applyFilters = () => { + _applyFilters(state); + }; + + return
    + + + + + + + + + + + + + + +
    + + +
    +
    +
    +
    ; + +} diff --git a/Tokenization/webapp/app/components/tokens/token-form.tsx b/Tokenization/webapp/app/components/tokens/token-form.tsx new file mode 100644 index 000000000..b512974aa --- /dev/null +++ b/Tokenization/webapp/app/components/tokens/token-form.tsx @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { FormInputNumber } from '~/components/form/form-input'; +import { FormSelectMulti, FormSelect } from '~/components/form/form-select'; +import { SelectGroup } from '~/components/form/select-group'; +import { ResetButton, SubmitButton } from '~/components/form/form-buttons'; +import { useTokenForm } from '~/hooks/tokens/token-form'; +import { Form } from '../form/form'; +import Modal from '~/components/window/modal'; +import { WindowTitle, WindowContent, WindowButtonAccept, WindowButtonCancel, WindowCloseIcon } from '~/components/window/window-objects'; +import Alert from '../window/alert'; +import { useAuth } from '~/utils/session'; +import { useEffect } from 'react'; + +const httpMethodOptions = [ + { value: 'GET', label: 'GET' }, + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + { value: 'DELETE', label: 'DELETE' }, +]; + +/** + * Not reusable Token Form component + */ +export function TokenForm() { + const { state, actions } = useTokenForm(); + const { fetcher, ref } = state; + + return ( + + + + {state.loaderData && ( + <> + + + + +
    + + +
    + + )} + + ); +} + +/** + * Not reusable Windows prepared for Token Form component + */ +export function TokenFormWindows() { + const { state, actions } = useTokenForm(); + const { fetcher } = state; + const { submit } = actions; + + const auth = useAuth('admin'); + const { setAlert, setOpenAlert, setOpenModal } = actions; + + const callApi = () => { + if (auth) { + submit(); + } else { + setAlert({ key: Date.now(), + title: 'Authorization error', + message: 'You cannot perform this action without authorization.', + success: false }); + setOpenAlert(true); + } + setOpenModal(false); + }; + + useEffect(() => { + if (fetcher.state === 'idle' && (fetcher.data as any)?.success === true) { + setAlert({ key: Date.now(), + title: 'Token created', + message: 'Token has been created successfully.', + success: true }); + setOpenAlert(true); + } else if (fetcher.state === 'idle' && (fetcher.data as any)?.success === false) { + setAlert({ key: Date.now(), + title: 'Token creation failed', + message: 'An error occurred while creating the token.', + success: false }); + setOpenAlert(true); + } + }, [fetcher, fetcher.state, setAlert, setOpenAlert]); + + return ( + <> + + Confirm Token Creation + +
    +
    Are you sure you want to create the token with the specified settings?
    +
    Service from: {state.firstLabel}
    +
    Service to: {state.secondLabel}
    +
    Expiration time: {state.expirationTime} hours
    +
    HTTP methods: {state.selectedMethods.join(', ')}
    +
    +
    + + +
    + + {state.alert?.title} + {state.alert?.message} + + + + ); +} diff --git a/Tokenization/webapp/app/components/tokens/token-table.tsx b/Tokenization/webapp/app/components/tokens/token-table.tsx new file mode 100644 index 000000000..57685b9c2 --- /dev/null +++ b/Tokenization/webapp/app/components/tokens/token-table.tsx @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { type Token } from './token'; + +import { useState } from 'react'; +import { Link } from 'react-router'; + +import { useAuth } from '~/utils/session'; +import { ActionBlockSolo, ActionBlockBulk } from './action-block'; +import Modal from '../window/modal'; +import Alert from '../window/alert'; +import { WindowTitle, WindowContent, WindowButtonCancel, WindowButtonAccept, WindowCloseIcon } from '../window/window-objects'; + +/** + * TokenTableBase + * + * Reusable token table presentational component. + * + * @param {object} props - component props + * @param {Token[]} props.tokens - array of token records to display + * @param {{ key: string; label: string; render?: (t: Token) => React.ReactNode }[]} props.columns - column definitions + * @param {(tokenId: string) => void} props.onActionClick - callback for action clicks + * + * Notes: + * - This component only renders the table. + * - Columns can provide a custom render function. If not provided the column will render token[col.key]. + */ +function TokenTableBase({ + tokens, + columns, +}: { + tokens: Token[]; + columns: { key: string; label: string | (() => React.ReactNode); render?: (t: Token) => React.ReactNode }[]; +}) { + + return ( +
    + + + + {columns.map((c) => ( + + ))} + + + + {tokens.map((token: Token) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
    {typeof c.label === 'function' ? c.label() : c.label}
    + { } + {col.render ? col.render(token) : String((token as any)[col.key] ?? '')} +
    +
    + ); +} + +const successInfo = { + title: 'Token(s) deleted', + content: 'Token(s) deleted successfully', +}; + +const failureInfo = { + title: 'Authorization error', + content: "You don't have permission to do that operation!", +}; + +/** + * TokenTableContainer + * + * Shared container for token table variants: handles modal/alert logic. + * + * @param props.tokens - token list + * @param props.columns - columns definition passed to TokenTableBase + */ +function TokenTableContainer({ + tokens, + columns, +}: { + tokens: Token[]; + columns: { key: string; label: string | (() => React.ReactNode); render?: (t: Token) => React.ReactNode }[]; +}) { + const [openM, setOpenM] = useState(false); + const [openA, setOpenA] = useState(false); + const [tokenId, setTokenId] = useState(''); + const auth = useAuth('admin'); + const [key, setKey] = useState(0); // Used to force re-mount of Alert component + + const deleteToken = () => { + if (auth) { + // eslint-disable-next-line no-console + console.log(`Deleting token no. ${tokenId}`); + } + setKey((prevKey) => prevKey + 1); + setOpenA(true); + setTokenId(''); + }; + + const [windowContent, setWindowContent] = useState(''); + + // Onclick handler for both bulk and solo action blocks + const onActionClick = (id: string) => { + if ( id === 'bulk') { + setWindowContent('Are you sure you want to delete ALL FILTERED tokens? Check filtering before proceeding.'); + } else { + setTokenId(id); + setWindowContent(`Are you sure you want to delete token with id: ${id}?`); + } + setOpenM(true); + }; + + // Wrap columns to inject ActionBlock components with proper handlers for bulk and solo actions + const wrappedColumns = columns.map((col) => + col.key === 'actions' + ? { + ...col, + label: typeof col.label === 'function' + ? () =>
    Actions onActionClick('bulk')} />
    + : col.label, + render: (t: Token) => onActionClick(t.tokenId)} />, + } + : col, + ); + + return ( + <> + + + Token delete + {windowContent} + + + + + + + {auth ? successInfo.title : failureInfo.title} + {auth ? successInfo.content : failureInfo.content} + + + + ); +} + +// Common columns for all table variants +const columns = [ + { key: 'tokenId', label: 'ID', render: (t: Token) => {t.tokenId} }, + { key: 'serviceFrom', label: 'Service From' }, + { key: 'serviceTo', label: 'Service To' }, + { key: 'exp', label: 'Expires at' }, + { + key: 'actions', + label: 'Actions', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + render: (t: Token) => (null), + }, +]; + +/** + * TokenTable + * + * Original table using standard columns. + * @param props.tokens - token list + */ +export function TokenTable({ tokens }: { tokens: Token[] }) { + // Delegate to container; TokenTableContainer will call onRequestAction internally via same ActionBlock usage pattern. + return ; +} + +/** + * TokenTableWithIssuedAt + * + * Variant that adds "Issued at" and "HTTP Methods (permissions)" columns. + * @param props.tokens - token list + */ +export function TokenTableExtended({ tokens }: { tokens: Token[] }) { + const columns_extended = [ + ...columns.slice(0, 4), + { key: 'iat', label: 'Issued at', render: (t: Token) => String((t as any).iat ?? '') }, + { key: 'perm', label: 'HTTP Methods', render: (t: Token) => String((t as any).permissions.join(', ') ?? '') }, + { + key: 'actions', + label: () => (null), // Updated in TableContainer + // eslint-disable-next-line @typescript-eslint/no-unused-vars + render: (t: Token) => (null), // Updated in TableContainer + }, + ]; + + return ; +} diff --git a/Tokenization/webapp/app/components/window/alert.tsx b/Tokenization/webapp/app/components/window/alert.tsx new file mode 100644 index 000000000..07ea6dcb2 --- /dev/null +++ b/Tokenization/webapp/app/components/window/alert.tsx @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { type WindowInterface } from './window.d'; +import { useFullWindowLogic } from '../hooks/useWindowLogic'; + +// Used to represent an alert message +// Key might be used to force re-mounting the component for repeated alerts +export interface AlertType { + key: number; + title: string; + message: string; + success: boolean; +} + +/** + * Alert + * + * Small transient alert window. + * + * @param {object} props - component props (see WindowInterface in window.d.ts) + * @param {React.ReactNode} props.children - alert content (typically WindowTitle + WindowContent + optional WindowCloseIcon) + * @param {boolean} props.open - whether the modal is mounted (provided via DPB) + * @param {React.Dispatch>} props.setOpen - dispatcher to control mounting (provided via DPB) + * @param {() => void} [props.onClose] - optional callback invoked when alert closes + * @param {number|null} [props.timeout] - optional auto-close timeout in milliseconds (useful for transient alerts) + * @param {string} [props.className] - additional CSS classes applied to the modal container + * (expected to be used to control bg-color, but can be more versatile) + * Behaviour: + * - Delegates lifecycle (timeout, close action) and child wiring to useFullWindowLogic(props). + * - In contrast to Modal, doesn't use visibility control through CSS, but simply doesn't render if !open. + * - Thanks to that, timeout-based auto-close works more expectably. + * - Renders title, content and closeIcon produced by the hook. + */ +const Alert = (props: WindowInterface) => { + + const { className } = props; + const { ui_elements: { title, content, closeIcon } } = useFullWindowLogic(props); + + const { open } = props; + if (!open) { + return null; + } + + return ( +
    +
    + {title ?? ''} + {closeIcon ?? ''} +
    +
    +
    + {content ?? ''} +
    +
    +
    + ); +}; + +export default Alert; diff --git a/Tokenization/webapp/app/components/window/modal.tsx b/Tokenization/webapp/app/components/window/modal.tsx new file mode 100644 index 000000000..99fa0875f --- /dev/null +++ b/Tokenization/webapp/app/components/window/modal.tsx @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { type WindowInterface } from './window.d'; +import { useFullWindowLogic } from '../hooks/useWindowLogic'; + +/** + * Modal + * + * Application modal window component. + * + * @param {object} props - component props (see WindowInterface in window.d.ts) + * @param {React.ReactNode} props.children - modal content (title/content/buttons expected as Window* + * from ./window-objects.tsx children) + * @param {boolean} props.open - whether the modal is visible (provided via DPB) + * @param {React.Dispatch>} props.setOpen - dispatcher to control visibility (provided via DPB) + * @param {() => void} [props.onOpen] - optional callback invoked when modal opens + * @param {() => void} [props.onClose] - optional callback invoked when modal closes + * @param {number|null} [props.timeout] - optional auto-close timeout in milliseconds + * @param {string} [props.className] - additional CSS classes applied to the modal container + * (expected to be used to control bg-color, but can be more versatile) + * + * Behaviour: + * - Uses useFullWindowLogic(props) to wire lifecycle (open/close/timeout) and to extract/wire Window child elements: + * title, content, closeIcon, buttonCancel, buttonAccept. + * - Renders those wired elements inside a modal overlay. + */ +const Modal = (props: WindowInterface) => { + const { className } = props; + const { visibility, ui_elements: { title, content, closeIcon, buttonCancel, buttonAccept } } = useFullWindowLogic(props); + + return ( +
    +
    +
    + {title ?? ''} + {closeIcon ?? ''} +
    +
    +
    + {content ?? ''} +
    +
    + {buttonCancel ?? ''} + {buttonAccept ?? ''} +
    +
    +
    +
    + ); +}; + +export default Modal; diff --git a/Tokenization/webapp/app/components/window/window-objects.tsx b/Tokenization/webapp/app/components/window/window-objects.tsx new file mode 100644 index 000000000..4581eda5e --- /dev/null +++ b/Tokenization/webapp/app/components/window/window-objects.tsx @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { type PropsWithChildren } from 'react'; + +import { type ButtonInterface } from './window.d'; +import { IconContainer, IconX } from '~/ui/icon'; + +/** + * WindowTitle + * + * Small presentational component used as a child of Modal/Alert to mark the title region. + * + * @param {object} props - component props + * @param {React.ReactNode} props.children - title content + */ +export const WindowTitle = ({ children }: PropsWithChildren) => +

    + {children} +

    ; + +/** + * WindowContent + * + * Presentational wrapper for main window content. + * + * @param {object} props - component props + * @param {React.ReactNode} props.children - content to display inside the window body + */ +export const WindowContent = ({ children }: PropsWithChildren) => + + {children} + ; + +/** + * WindowButton / WindowButtonCancel / WindowButtonAccept + * + * Reusable button components used inside Window children. + * + * @param {object} props - component props + * @param {React.ReactNode} props.children - button label/content + * @param {() => void} [props.action] - callback invoked on click (wrapped by parent logic when cloned) + * @param {string} [props.className] - additional CSS classes + * + * Notes: + * - Parent window logic (useFullWindowLogic) clones these elements and injects action handlers. + */ +const WindowButton = ({ children, action, className }: ButtonInterface) => + ; + +export const WindowButtonCancel = ({ action }: ButtonInterface) => + + Cancel + ; + +export const WindowButtonAccept = ({ action, className }: ButtonInterface) => + + Accept + ; + +/** + * WindowCloseIcon + * + * Clickable close icon used in window chrome. + * + * @param {object} props - component props + * @param {() => void} [props.action] - click handler (parent will inject cancel/close action) + * @param {string} [props.className] - additional CSS classes + */ +export const WindowCloseIcon = ({ action, className }: ButtonInterface) => +
    + + + +
    ; diff --git a/Tokenization/webapp/app/components/window/window.d.ts b/Tokenization/webapp/app/components/window/window.d.ts new file mode 100644 index 000000000..dc7f106c7 --- /dev/null +++ b/Tokenization/webapp/app/components/window/window.d.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import type React from 'react'; +import { type PropsWithChildren } from 'react'; +import { type DialogPropsBase as DPB } from '~/utils/types'; + +export interface WindowElements { + title: React.ReactElement; + content: React.ReactElement; + buttonCancel: React.ReactElement; + buttonAccept: React.ReactElement; + closeIcon: React.ReactElement; +} + +export interface WindowElementsWithAction extends WindowElements { + acceptAction: () => void; + cancelAction: () => void; +} + +export interface WindowInterface extends PropsWithChildren, React.HTMLAttributes, DPB { + onOpen?: () => void; + onClose?: () => void; + timeout?: number | null; +} + +export interface ButtonInterface extends PropsWithChildren, React.ButtonHTMLAttributes { + action?: () => void; +} diff --git a/Tokenization/webapp/app/contexts/tokens/token-filters.tsx b/Tokenization/webapp/app/contexts/tokens/token-filters.tsx new file mode 100644 index 000000000..20aad4c93 --- /dev/null +++ b/Tokenization/webapp/app/contexts/tokens/token-filters.tsx @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React, { useCallback, useState } from 'react'; + +import type { OptionType } from '~/utils/types'; + +type State = { + services: OptionType[]; + firstSelectedService: string[]; + secondSelectedService: string[]; + httpMethods: string[]; + expirationDateMin: string; + expirationDateMax: string; + issueDateMin: string; + issueDateMax: string; + ordering: string[]; +}; + +type Actions = { + setServices: React.Dispatch>; + setFirstSelectedService: React.Dispatch>; + setSecondSelectedService: React.Dispatch>; + setHttpMethods: React.Dispatch>; + setExpirationDateMin: React.Dispatch>; + setExpirationDateMax: React.Dispatch>; + setIssueDateMin: React.Dispatch>; + setIssueDateMax: React.Dispatch>; + setOrdering: React.Dispatch>; + clearAllFilters: () => void; +}; + +export const TokenFiltersContext = React.createContext<{ state: State; actions: Actions } | undefined>(undefined); + +/** + * TokenFiltersProvider + * + * Context provider component for token filters state management. + * + * @param {object} props - component props + * @param {React.ReactNode} props.children - child components + * + * @returns {JSX.Element} - rendered component + */ +export function TokenFiltersProvider({ children }: { children: React.ReactNode }) { + const [services, setServices] = useState([]); + const [firstSelectedService, setFirstSelectedService] = useState([]); + const [secondSelectedService, setSecondSelectedService] = useState([]); + const [httpMethods, setHttpMethods] = useState([]); + const [expirationDateMin, setExpirationDateMin] = useState(''); + const [expirationDateMax, setExpirationDateMax] = useState(''); + const [issueDateMin, setIssueDateMin] = useState(''); + const [issueDateMax, setIssueDateMax] = useState(''); + const [ordering, setOrdering] = useState([]); + + const clearAllFilters = useCallback(() => { + setFirstSelectedService([]); + setSecondSelectedService([]); + setHttpMethods([]); + setExpirationDateMin(''); + setExpirationDateMax(''); + setIssueDateMin(''); + setIssueDateMax(''); + setOrdering([]); + }, [ + setServices, + setFirstSelectedService, + setSecondSelectedService, + setHttpMethods, + setExpirationDateMin, + setExpirationDateMax, + setIssueDateMin, + setIssueDateMax, + setOrdering, + ]); + + const state = React.useMemo(() => ({ + services, + firstSelectedService, + secondSelectedService, + httpMethods, + expirationDateMin, + expirationDateMax, + issueDateMin, + issueDateMax, + ordering, + }), [ + services, + firstSelectedService, + secondSelectedService, + httpMethods, + expirationDateMin, + expirationDateMax, + issueDateMin, + issueDateMax, + ordering, + ]) ; + + const actions = React.useMemo(() => ({ + setServices, + setFirstSelectedService, + setSecondSelectedService, + setHttpMethods, + setExpirationDateMin, + setExpirationDateMax, + setIssueDateMin, + setIssueDateMax, + setOrdering, + clearAllFilters, + }), [ + setServices, + setFirstSelectedService, + setSecondSelectedService, + setHttpMethods, + setExpirationDateMin, + setExpirationDateMax, + setIssueDateMin, + setIssueDateMax, + setOrdering, + clearAllFilters, + ]) ; + + return + {children} + ; + +} diff --git a/Tokenization/webapp/app/contexts/tokens/token-form.tsx b/Tokenization/webapp/app/contexts/tokens/token-form.tsx new file mode 100644 index 000000000..7634ff411 --- /dev/null +++ b/Tokenization/webapp/app/contexts/tokens/token-form.tsx @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React, { createContext, useCallback, useEffect, useMemo, useState } from 'react'; +import type { OptionType, HttpMethod } from '~/utils/types'; +import type { AlertType } from '~/components/window/alert'; +import useForm from '~/components/hooks/useForm'; +import type { useFetcher } from 'react-router'; + +type State = { + loaderData?: OptionType[]; + expirationTime: string; + firstSelectedService: string; + secondSelectedService: string; + selectedMethods: HttpMethod[]; + firstLabel: string; + secondLabel: string; + openAlert: boolean; + openModal: boolean; + alert: AlertType | null; + fetcher: ReturnType; + ref: React.RefObject; +}; + +type Actions = { + setExpirationTime: React.Dispatch>; + setFirstSelectedService: React.Dispatch>; + setSecondSelectedService: React.Dispatch>; + setSelectedMethods: React.Dispatch>; + onSubmit: () => void; + onReset: () => void; + setOpenAlert: React.Dispatch>; + setOpenModal: React.Dispatch>; + setAlert: React.Dispatch>; + submit: () => void; +}; + +export const TokenFormContext = createContext<{ state: State; actions: Actions } | undefined>(undefined); + +/** + * Form context provider which holds all state and actions for Token Form. + * It is used to wrap the Token Form and its windows - for them to simplify the props passing. + * It can be the way for all routes to have their own context providers in the future. + * As its elegant and not global solution, actions and state can be used corectly only inside + * components wrapped by this provider. + * + * @example {} + * @usage It is used in webapp/app/hooks/tokens/token-form.tsx - by using useContext(TokenFormContext) + */ +export function TokenFormProvider({ loaderData, children }: { loaderData?: OptionType[]; children: React.ReactNode }) { + const [expirationTime, setExpirationTime] = useState(''); + const [firstSelectedService, setFirstSelectedService] = useState(''); + const [secondSelectedService, setSecondSelectedService] = useState(''); + const [selectedMethods, setSelectedMethods] = useState([]); + const [firstLabel, setFirstLabel] = useState(''); + const [secondLabel, setSecondLabel] = useState(''); + const [openAlert, setOpenAlert] = useState(false); + const [openModal, setOpenModal] = useState(false); + const [alert, setAlert] = useState(null); + const { fetcher, ref, submit } = useForm(); + + useEffect(() => { + if (!loaderData) { + return; + } + const f = loaderData.find(o => o.value === firstSelectedService)?.label ?? ''; + const s = loaderData.find(o => o.value === secondSelectedService)?.label ?? ''; + setFirstLabel(f); + setSecondLabel(s); + }, [firstSelectedService, secondSelectedService, loaderData]); + + const onSubmit = useCallback(() => { + if (expirationTime && firstSelectedService && secondSelectedService && selectedMethods.length > 0) { + setOpenModal(true); + } else { + let message = 'Please fill in all required fields: '; + if (!firstSelectedService) { + message += 'First service, '; + } + if (!secondSelectedService) { + message += 'Second service, '; + } + if (!expirationTime) { + message += 'Expiration time, '; + } + if (selectedMethods.length === 0) { + message += 'HTTP methods, '; + } + message = message.slice(0, -2); + setAlert({ key: Date.now(), title: 'Form incomplete', message, success: false }); + setOpenAlert(true); + } + }, [expirationTime, firstSelectedService, secondSelectedService, selectedMethods]); + + const onReset = useCallback(() => { + setExpirationTime(''); + setFirstSelectedService(''); + setSecondSelectedService(''); + setSelectedMethods([]); + }, [setExpirationTime, setFirstSelectedService, setSecondSelectedService, setSelectedMethods]); + + const state = useMemo(() => ({ + loaderData, + expirationTime, + firstSelectedService, + secondSelectedService, + selectedMethods, + firstLabel, + secondLabel, + openAlert, + openModal, + alert, + fetcher, + ref, + }), + [loaderData, + expirationTime, + firstSelectedService, + secondSelectedService, + selectedMethods, + firstLabel, + secondLabel, + openAlert, + openModal, + alert, + fetcher, + ref, + ]); + + const actions: Actions = useMemo(() => ({ + setExpirationTime, setFirstSelectedService, setSecondSelectedService, setSelectedMethods, + onSubmit, onReset, setAlert, setOpenAlert, setOpenModal, submit, + }), + [setExpirationTime, + setFirstSelectedService, + setSecondSelectedService, + setSelectedMethods, + setOpenAlert, + setOpenModal, + onSubmit, + onReset, + setAlert, + submit, + ]); + + return {children}; +} diff --git a/Tokenization/webapp/app/hooks/tokens/token-filters.tsx b/Tokenization/webapp/app/hooks/tokens/token-filters.tsx new file mode 100644 index 000000000..b422a3e5f --- /dev/null +++ b/Tokenization/webapp/app/hooks/tokens/token-filters.tsx @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { useContext } from 'react'; +import { TokenFiltersContext } from '~/contexts/tokens/token-filters'; + +/** + * Used to access Token Filters context created for Token Filters component + * in webapp/app/contexts/tokens/token-filters.tsx + */ +export function useTokenFilters() { + const ctx = useContext(TokenFiltersContext); + if (!ctx) { + throw new Error('useTokenFilters must be used inside TokenFiltersProvider'); + } + return ctx; +} diff --git a/Tokenization/webapp/app/hooks/tokens/token-form.tsx b/Tokenization/webapp/app/hooks/tokens/token-form.tsx new file mode 100644 index 000000000..2e91fbb88 --- /dev/null +++ b/Tokenization/webapp/app/hooks/tokens/token-form.tsx @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { useContext } from 'react'; +import { TokenFormContext } from '~/contexts/tokens/token-form'; + +/** + * Used to access Token Form context created for Token Form component. + * in webapp/app/contexts/tokens/token-form.tsx + */ +export function useTokenForm() { + const ctx = useContext(TokenFormContext); + if (!ctx) { + throw new Error('useTokenForm must be used inside TokenFormProvider'); + } + return ctx; +} diff --git a/Tokenization/webapp/app/routes.ts b/Tokenization/webapp/app/routes.ts index e19aebdd7..2cd5d66c9 100644 --- a/Tokenization/webapp/app/routes.ts +++ b/Tokenization/webapp/app/routes.ts @@ -20,6 +20,8 @@ export default [ ...prefix('tokens', [ index('routes/tokens/overview.tsx'), route(':tokenId', 'routes/tokens/details.tsx'), + route('table', 'routes/tokens/table.tsx'), + route('new', 'routes/tokens/create.tsx'), ]), route('*', 'routes/404.tsx'), ]), diff --git a/Tokenization/webapp/app/routes/home.tsx b/Tokenization/webapp/app/routes/home.tsx index bfc99ffb1..0b1b459eb 100644 --- a/Tokenization/webapp/app/routes/home.tsx +++ b/Tokenization/webapp/app/routes/home.tsx @@ -11,7 +11,6 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ - import { Link } from 'react-router'; import { useSetHeader } from '~/ui/header/headerContext'; @@ -22,8 +21,7 @@ import { useSetHeader } from '~/ui/header/headerContext'; */ export default function Home() { - const { setHeaderContent } = useSetHeader(); - setHeaderContent('Tokenization Admin Interface'); + useSetHeader('Tokenization Admin Interface'); return <>

    Welcome to (dummy) Tokenization GUI!

    diff --git a/Tokenization/webapp/app/routes/tokens/create.tsx b/Tokenization/webapp/app/routes/tokens/create.tsx new file mode 100644 index 000000000..7eed5b520 --- /dev/null +++ b/Tokenization/webapp/app/routes/tokens/create.tsx @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import type { Route } from './+types/overview'; + +import { TokenFormProvider } from '~/contexts/tokens/token-form'; +import { TokenForm, TokenFormWindows } from '~/components/tokens/token-form'; +import { Box1_1 } from '~/components/box'; +import type { OptionType } from '~/utils/types'; + +// eslint-disable-next-line jsdoc/require-jsdoc +export async function clientAction({ request }: Route.ClientActionArgs) { + const formData = await request.formData(); + // eslint-disable-next-line no-console + console.log(Object.fromEntries(formData.entries())); + return { success: true }; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export function clientLoader(): OptionType[] { + return [ + { value: 'service1', label: 'Service 1' }, + { value: 'service2', label: 'Service 2' }, + { value: 'service3', label: 'Service 3' }, + { value: 'service4', label: 'Service 4' }, + ]; +} + +/** + * Component is used for /tokens/new route to create new tokens. + */ +export default function CreateToken({ loaderData }: { loaderData?: OptionType[] }) { + return ( + + + + + + + ); +} diff --git a/Tokenization/webapp/app/routes/tokens/overview.tsx b/Tokenization/webapp/app/routes/tokens/overview.tsx index 643200ee1..111ef6d8d 100644 --- a/Tokenization/webapp/app/routes/tokens/overview.tsx +++ b/Tokenization/webapp/app/routes/tokens/overview.tsx @@ -11,148 +11,60 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import type { Route } from './+types/overview'; import type { Token } from '../../components/tokens/token'; -import { Link } from 'react-router'; -import { useState } from 'react'; -import { Tab, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@mui/material'; +import { Await, useLoaderData } from 'react-router'; +import { Suspense } from 'react'; -import ActionBlock from '~/components/tokens/action-block'; +import { Spinner } from '~/ui/spinner'; +import { Box1_2 } from '../../components/box'; import { useSetHeader } from '~/ui/header/headerContext'; -import { TabsNavbar } from '~/ui/navbar'; -import { useAuth } from '~/utils/session'; +import { TokenTable } from '../../components/tokens/token-table'; /** * Client loader that fetches all tokens from the API. * * @returns Promise that resolves to an array of tokens */ -export const clientLoader = async (): Promise => { - const response = await fetch('/api/tokens'); - if (!response.ok) { - throw new Error('An error occurred!'); - } - return response.json(); +export const clientLoader = async (): Promise<{ tokens: Promise }> => { + const tokens = fetch('/api/tokens') + .then(response => { + if (!response.ok) { + throw new Error('An error occurred!'); + } + return response.json(); + }); + + return { tokens }; }; -// Interfejs stanu dla okna dialogowego usuwania tokena -interface DeleteDialogState { - isOpen: boolean; - tokenId: string; -} - -// Will be changed in next PR -/** - * Table component that displays a list of tokens with their ID and validity. - * Token IDs are clickable links that navigate to the token details page. - * - * @param tokens - Array of tokens to display - */ -function TokenTable({ tokens }: { tokens: Token[] }) { - const theaders = ['ID', 'Service From', 'Service To', 'Expires at', 'Actions']; - - // State for dialog window - const [deleteDialog, setDeleteDialog] = useState({ - isOpen: false, - tokenId: '', - }); - - const auth = useAuth('admin'); - - // Function to handle delete confirmation -> will be updated in next PR - const handleConfirmDelete = async () => { - if (auth) { - // eslint-disable-next-line no-console - console.log('Token Deleted'); - } - setDeleteDialog({ isOpen: false, tokenId: '' }); - }; - - const handleCloseDialog = () => { - setDeleteDialog({ isOpen: false, tokenId: '' }); - }; - - return ( - <> - - - - {theaders.map((content, index) => )} - - - - {tokens.map((token: Token) => ( - - - - - - - - ))} - -
    {content}
    {token.tokenId}{token.serviceFrom}{token.serviceTo}{token.exp.split('T').reverse().join(' - ')} - -
    - - - - Confirm Token Deletion - - - - Are you sure you want to delete token with ID: {deleteDialog.tokenId}? - This action cannot be undone. - - - - - - - - - ); -} - /** * Tokens overview page component with tabbed interface. - * Displays a list of tokens and provides a placeholder for token creation. + * Displays a list of tokens * - * @param loaderData - Array of tokens loaded by the client loader + * @param loaderData - Object containing the deferred tokens promise */ -export default function Overview({ loaderData: tokens }: Route.ComponentProps) { +export default function Overview() { - const { setHeaderContent } = useSetHeader(); - setHeaderContent('Tokens'); + const { tokens } = useLoaderData(); + useSetHeader('Tokens'); - const [tabIndex, setTabIndex] = useState(0); - - return
    - - - - - - { - tabIndex == 0 ? - : + return ( +
    + + }> + + {(resolvedTokens: Token[]) => } + + + + +

    Create Token

    Form to create a new token will go here.

    - } -
    ; + +
    + ); } diff --git a/Tokenization/webapp/app/routes/tokens/table.tsx b/Tokenization/webapp/app/routes/tokens/table.tsx new file mode 100644 index 000000000..d7a7b5292 --- /dev/null +++ b/Tokenization/webapp/app/routes/tokens/table.tsx @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Suspense } from 'react'; +import { useLoaderData } from 'react-router'; + +import { Box1_2 } from '~/components/box'; +import { Spinner } from '~/ui/spinner'; +import { Await } from 'react-router'; +import type { Token } from '~/components/tokens/token'; +import { TokenTableExtended } from '~/components/tokens/token-table'; +import { TokenFilters } from '~/components/tokens/token-filters'; +import { TokenFiltersProvider } from '~/contexts/tokens/token-filters'; + +//eslint-disable-next-line jsdoc/require-jsdoc +export function clientLoader() { + const tokens = fetch('/api/tokens') + .then(response => { + if (!response.ok) { + throw new Error('An error occurred!'); + } + return response.json(); + }); + + return { tokens }; +} + +//eslint-disable-next-line jsdoc/require-jsdoc +export default function TokensTable() { + const { tokens } = useLoaderData(); + return + +
    + + }> + + {(resolvedTokens: Token[]) => } + + +
    +
    ; +} diff --git a/Tokenization/webapp/app/styles/components-styles.css b/Tokenization/webapp/app/styles/components-styles.css index e318f84e6..dc2389712 100644 --- a/Tokenization/webapp/app/styles/components-styles.css +++ b/Tokenization/webapp/app/styles/components-styles.css @@ -11,4 +11,121 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ - \ No newline at end of file + + .grid-1-2 { + display: grid; + grid-template-columns: 0.5fr 0.5fr; + grid-auto-rows: 1fr; + } + +.min-height-box-1 { + min-height: 88vh; +} + +.min-height-box-2 { + min-height: 44vh; +} + +.min-height-box-3 { + min-height: 22vh; +} + +/* Modal */ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); +} + +.modal { + width: clamp(50px, 30%, 800px); + height: auto; + position: fixed; + top: 40%; + left: 50%; + transform: translate(-50%, -50%); + border: 2px solid black; +} + +/* Alert */ +.alert { + width: clamp(100, 10%, 300px); + position: fixed; + top: 10%; + right: 2%; +} + +/* Select components */ + +.my-select { + width: 100%; + padding: 8px 12px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 6px; + background-color: #fff; + color: #333; + cursor: pointer; + transition: border 0.2s, box-shadow 0.2s; + position: relative; +} + +ul.multiselect-list { + list-style: none; + padding: 0; + margin: 0; +} + +.multiselect-list > li { + border-radius: 10px; + border: 0.1rem solid black; + margin-right: 0.2rem; + padding-left: 0.4rem; +} + +.multiselect-list button { + border-radius: 50%; + margin-left: 0.5rem; + border: none; + background-color: var(--color-transparent); + cursor: pointer; +} + +.multiselect-list button:hover { + border: 0.1rem solid black; +} + +/* input */ +/* .my-input { + width: 100%; + padding: 8px 12px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 6px; + background-color: #fff; + color: #333; + cursor: pointer; + transition: border 0.2s, box-shadow 0.2s; + position: relative; +} */ + +.my-input label { + margin: 0; +} + +.my-input input { + width: 100%; + padding: 8px 12px; + font-size: 20px; + border: 2px solid black; + border-radius: 6px; + background-color: #fff; + color: #333; + cursor: pointer; + transition: border 0.2s, box-shadow 0.2s; + position: relative; +} \ No newline at end of file diff --git a/Tokenization/webapp/app/styles/ui-styles.css b/Tokenization/webapp/app/styles/ui-styles.css index ed368fe14..29094a14d 100644 --- a/Tokenization/webapp/app/styles/ui-styles.css +++ b/Tokenization/webapp/app/styles/ui-styles.css @@ -12,19 +12,39 @@ * or submit itself to any jurisdiction. */ - .container { - display: grid; - grid-template-rows: 1fr 3fr 1.3fr; - grid-template-columns: 0.575fr 3fr; + +/* Nav-item similar to menu-item */ +nav ul { + list-style: none +} + +.nav-item { + cursor: pointer; + text-decoration: none; + border-radius: 0.25em; +} + +.nav-item > a { + text-decoration: none; +} + +.nav-item > a > div { + padding: 0.4em; + padding-left: 1em; + padding-right: 1em; } -.header-1 { +.nav-item:hover { + background-color: rgb(60, 60, 60); +} +/**/ +.header { justify-self: stretch; grid-column: span 2; } -.sidebar-1 { - border-radius: 2% 0 0 2% ; - grid-row: span 2; +.logo-fluid { + height: 40px; + margin: 0.2em 1em } diff --git a/Tokenization/webapp/app/ui/flex.tsx b/Tokenization/webapp/app/ui/flex.tsx new file mode 100644 index 000000000..17a5fc7bb --- /dev/null +++ b/Tokenization/webapp/app/ui/flex.tsx @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React from 'react'; + +import { checkIsComponentOfType } from '~/utils/component-type-checker'; +/** + * React component that wraps all children in a flex-grow div. + * + * @param props.children - React nodes to be wrapped + * @param props.className - additional class names to add to the wrapper div + * @returns + */ +export function FlexGrowWrapperElement({ children, className }: { children: React.ReactNode; className?: string }) { + return
    {children}
    ; +} + +/** + * React component that Wraps all children in a flex-grow div inside a flex-row container. + * If element is already a FlexGrowWrapperElement, it is not wrapped again. + * + * @param props.children - React nodes to be wrapped + */ +export function FlexGrowWrapper({ children }: { children: React.ReactNode }) { + let wrappedChildren; + if (children) { + const arrayChildren = React.Children.toArray(children); + wrappedChildren = arrayChildren.map((child: React.ReactNode, index: number) => ( + checkIsComponentOfType(child, FlexGrowWrapperElement)) ? + {child} : +
    {child}
    , + ); + } else { + wrappedChildren = children; + } + + return
    {wrappedChildren}
    ; +} diff --git a/Tokenization/webapp/app/ui/header/header.tsx b/Tokenization/webapp/app/ui/header/header.tsx index 651cca851..fb8d392d8 100644 --- a/Tokenization/webapp/app/ui/header/header.tsx +++ b/Tokenization/webapp/app/ui/header/header.tsx @@ -12,36 +12,18 @@ * or submit itself to any jurisdiction. */ -import { Link } from 'react-router'; - -import { IconHome, IconCog } from '../icon'; - /** * AppHeader * * Displays the application header with navigation icons (home, settings) and a customizable title. - * @param headerContent.headerContent - * @param headerContent Optional string to display as the header title. + * @param props.children - standard usage of React children prop */ -export function AppHeader({ headerContent }: { headerContent?: string }) { +export function AppHeader({ children }: { children?: React.ReactNode }) { return ( -
    - - -
    - -
    - - -
    - -
    - - -
    -

    {headerContent ?? 'Tokenization Admin Interface'}

    -
    - -
    +
    +
    + {children} +
    +
    ); } diff --git a/Tokenization/webapp/app/ui/header/headerContext.tsx b/Tokenization/webapp/app/ui/header/headerContext.tsx index 467b8a352..e47e8cca6 100644 --- a/Tokenization/webapp/app/ui/header/headerContext.tsx +++ b/Tokenization/webapp/app/ui/header/headerContext.tsx @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { createContext, useContext } from 'react'; +import { createContext, useContext, useEffect } from 'react'; /** * HeaderContext @@ -30,8 +30,13 @@ export const HeaderContext = createContext({ /** * UseSetHeader * - * Custom React hook to access the HeaderContext. - * Allows components to call setHeaderContent to update the header text. - * @return The context value containing setHeaderContent. + * Changes content of header + * @param headerContent new value for context */ -export const useSetHeader = () => useContext(HeaderContext); +export const useSetHeader = (headerContent: string) => { + const { setHeaderContent } = useContext(HeaderContext); + + useEffect(() => { + setHeaderContent(headerContent); + }, [setHeaderContent, headerContent]); +}; diff --git a/Tokenization/webapp/app/ui/layout.tsx b/Tokenization/webapp/app/ui/layout.tsx index 774ed0c14..e997f8c25 100644 --- a/Tokenization/webapp/app/ui/layout.tsx +++ b/Tokenization/webapp/app/ui/layout.tsx @@ -13,32 +13,38 @@ */ import { Outlet, useNavigation } from 'react-router'; -import { useState } from 'react'; import { AppHeader } from './header/header'; -import { HeaderContext } from './header/headerContext'; import { AppSidebar } from './sidebar'; import { Spinner } from './spinner'; +import logo from '../assets/4_Color_Logo_CB.png'; + +const Logo = ({ name }: { name: string }) => ( +
    + +

    {name}

    +
    +); + /** * Component provides main layout for the application * Uses useNavigation state to check if page is loaded */ export default function Layout() { - const { state } = useNavigation(); - const [headerContent, setHeaderContent] = useState('Tokenization Admin Interface'); + const { state } = useNavigation(); return ( - -
    - +
    + + -
    - {state === 'loading' ? : } -
    +
    +
    + {state === 'loading' ? : }
    - +
    ); } diff --git a/Tokenization/webapp/app/ui/navbar.tsx b/Tokenization/webapp/app/ui/navbar.tsx index d996641a2..70aa2712e 100644 --- a/Tokenization/webapp/app/ui/navbar.tsx +++ b/Tokenization/webapp/app/ui/navbar.tsx @@ -13,11 +13,10 @@ */ import React from 'react'; -import { Box, Tabs } from '@mui/material'; +import { NavLink } from 'react-router'; +import { Box, Tab } from '@mui/material'; interface TabsNavbarArguments { - tabIndex: number; - setTabIndex: (index: number) => void; children?: React.ReactNode; } @@ -30,23 +29,29 @@ interface TabsNavbarArguments { * @param setTabIndex Function to update the selected tab index. * @param children Tab (@mui/material) components (usually ) to be rendered inside the navigation bar. */ -export function TabsNavbar({ tabIndex, setTabIndex, children }: TabsNavbarArguments) { - - const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { - setTabIndex(newValue); - }; - +export function TabsNavbar({ children }: TabsNavbarArguments) { return ( - - {children} - + {children} ); } + +interface LinkTabProps { + label: string; + to: string; +} + +export const LinkTab = ({ label, to }: LinkTabProps) => ( + +); diff --git a/Tokenization/webapp/app/ui/sidebar.tsx b/Tokenization/webapp/app/ui/sidebar.tsx index 0e701317d..8b6fbc531 100644 --- a/Tokenization/webapp/app/ui/sidebar.tsx +++ b/Tokenization/webapp/app/ui/sidebar.tsx @@ -13,8 +13,8 @@ */ import type { NavLinkProps } from 'react-router'; +import React from 'react'; import { NavLink } from 'react-router'; -import Button from '@mui/material/Button'; type StyledNavLinkProps = { children: React.ReactNode; @@ -24,23 +24,35 @@ type StyledNavLinkProps = { /** * StyledNavLink * - * A wrapper component that renders a Material-UI Button styled as a navigation link. + * A wrapper component that renders styled navigation link. * It uses NavLink from react-router to determine if the link is active and applies * the 'contained' variant for the active route and 'outlined' for inactive routes. * @param children The content to display inside the button. * @param to The target route path. */ -const StyledNavLink = ({ children, to }: StyledNavLinkProps) => - {({ isActive }) => ( - - )} -; +const StyledNavLink = ({ children, to }: StyledNavLinkProps) => + + {({ isActive }) => ( +
    + {children} +
    + )} +
    ; + +const NavList = ({ children }: { children: React.ReactNode }) => { + const items = React.Children.toArray(children); + return
      + { + items.map((el, idx) => +
    • + {el} +
    • , + ) + } +
    ; +}; /** * AppSidebar @@ -51,9 +63,11 @@ const StyledNavLink = ({ children, to }: StyledNavLinkProps) => - ; +
    + +
    ; diff --git a/Tokenization/webapp/app/utils/component-type-checker.tsx b/Tokenization/webapp/app/utils/component-type-checker.tsx new file mode 100644 index 000000000..91179a3ca --- /dev/null +++ b/Tokenization/webapp/app/utils/component-type-checker.tsx @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import React from 'react'; + +/** + * One-line helper function which checks if a component is of a certain type + * + * @param {React.ReactNode} c - component to check + * @param {React.ElementType} otype - component type to check against + * @returns {Boolean} true if component is of the specified type, false otherwise + */ +export const checkIsComponentOfType = (c: React.ReactNode, otype: React.ElementType): boolean => React.isValidElement(c) && c.type === otype; diff --git a/Tokenization/webapp/app/utils/types.ts b/Tokenization/webapp/app/utils/types.ts new file mode 100644 index 000000000..603ac464f --- /dev/null +++ b/Tokenization/webapp/app/utils/types.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +export interface OptionType { + value: string; + label: string; +} + +export type HttpMethod = 'GET' | 'POST' | 'DELETE' | 'PUT'; + +export interface DialogPropsBase { + open: boolean; + setOpen: React.Dispatch>; +} diff --git a/Tokenization/webapp/eslint.config.js b/Tokenization/webapp/eslint.config.js index 63f2c6984..3bd828c37 100644 --- a/Tokenization/webapp/eslint.config.js +++ b/Tokenization/webapp/eslint.config.js @@ -107,7 +107,7 @@ export default [ }, }, rules: { - // JS-only stylistic rules (aplikowane globalnie) + // JS-only stylistic rules '@stylistic/js/indent': ['error', 2], '@stylistic/js/quotes': ['error', 'single', { avoidEscape: true }], '@stylistic/js/semi': 'error', @@ -213,7 +213,7 @@ export default [ }, ], - // === TYPESCRIPT STYLISTIC RULES (moved here) === + // === TYPESCRIPT STYLISTIC RULES === '@stylistic/ts/comma-dangle': ['error', 'always-multiline'], '@stylistic/ts/comma-spacing': ['error', { before: false, after: true }], '@stylistic/ts/indent': ['error', 2], @@ -226,7 +226,6 @@ export default [ '@stylistic/ts/type-annotation-spacing': 'error', '@stylistic/ts/member-delimiter-style': 'error', - // keep JS stylistic ones if you need them additionally in TS: '@stylistic/js/array-bracket-spacing': ['error', 'never'], '@stylistic/js/brace-style': ['error', '1tbs'], '@stylistic/js/no-trailing-spaces': 'error', diff --git a/Tokenization/webapp/package-lock.json b/Tokenization/webapp/package-lock.json index 8af547000..09b21afd4 100644 --- a/Tokenization/webapp/package-lock.json +++ b/Tokenization/webapp/package-lock.json @@ -47,6 +47,11 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.5.0", +<<<<<<< HEAD + "react-select": "^5.10.2", + "typescript": "^5.7.2", +======= +>>>>>>> origin/dev >>>>>>> origin/dev "vite": "^6.0.11", "vite-tsconfig-paths": "^5.1.4" @@ -742,50 +747,450 @@ <<<<<<< HEAD "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6", - "@typescript-eslint/types": "^8.11.0", - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~4.1.0" - }, + "dependencies": { + "@types/estree": "^1.0.6", + "@typescript-eslint/types": "^8.11.0", + "comment-parser": "1.4.1", + "esquery": "^1.6.0", + "jsdoc-type-pratt-parser": "~4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], +======= +>>>>>>> origin/dev + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6", + "@typescript-eslint/types": "^8.11.0", + "comment-parser": "1.4.1", + "esquery": "^1.6.0", + "jsdoc-type-pratt-parser": "~4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", "cpu": [ - "ppc64" + "ia32" ], -======= ->>>>>>> origin/dev - "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6", - "@typescript-eslint/types": "^8.11.0", - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~4.1.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-x64": { + "node_modules/@esbuild/win32-x64": { "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", - "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">=18" @@ -996,6 +1401,31 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2200,6 +2630,201 @@ "integrity": "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.52.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", @@ -2226,6 +2851,71 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -5881,7 +6571,10 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", +<<<<<<< HEAD +======= "dev": true, +>>>>>>> origin/dev "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5891,8 +6584,11 @@ "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } +<<<<<<< HEAD +======= ======= "license": "ISC" +>>>>>>> origin/dev >>>>>>> origin/dev }, "node_modules/function-bind": { @@ -7562,6 +8258,12 @@ "node": ">= 0.6" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -9074,6 +9776,27 @@ "node": ">=18" } }, + "node_modules/react-select": { + "version": "5.10.2", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", + "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -10228,6 +10951,9 @@ "license": "MIT", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-json-comments": { @@ -10739,6 +11465,20 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/Tokenization/webapp/package.json b/Tokenization/webapp/package.json index 9571bd04a..5c449fc9f 100644 --- a/Tokenization/webapp/package.json +++ b/Tokenization/webapp/package.json @@ -8,7 +8,7 @@ "build": "react-router build", "dev": "react-router dev", "start": "react-router-serve ./build/server/index.js", - "test": "mocha --exit tests/mocha-index.cjs", + "test": "mocha --timeout 10000 --exit tests/mocha-index.cjs", "typecheck": "react-router typegen && tsc", "docker:typecheck": "docker compose exec webapp npm run typecheck", "docker:test:build": "cd .. && docker compose -f docker-compose.test.yml up --build --abort-on-container-exit ui-tests && docker compose -f docker-compose.test.yml stop && cd ./webapp", @@ -31,15 +31,17 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/material": "^7.1.1", + "@react-router/dev": "^7.5.0", "@react-router/node": "^7.5.0", + "@types/node": "^20", + "@types/react": "^19.0.1", + "@types/react-dom": "^19.0.1", "isbot": "^5", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.5.0", - "@react-router/dev": "^7.5.0", - "@types/node": "^20", - "@types/react": "^19.0.1", - "@types/react-dom": "^19.0.1", + "react-select": "^5.10.2", + "typescript": "^5.7.2", "vite": "^6.0.11", "vite-tsconfig-paths": "^5.1.4" }, diff --git a/Tokenization/webapp/tests/mocha-index.cjs b/Tokenization/webapp/tests/mocha-index.cjs index 2133154d4..957a38b38 100644 --- a/Tokenization/webapp/tests/mocha-index.cjs +++ b/Tokenization/webapp/tests/mocha-index.cjs @@ -58,7 +58,10 @@ describe('Tokenization', function() { global.test.helpers.url = url; }); - require('./public/basic.cjs'); + require('./public/layout-tests.cjs'); + require('./public/tokens/token-creation-err.cjs'); + require('./public/tokens/token-happy-path.cjs'); + require('./public/tokens/token-deletion-err.cjs'); beforeEach(function () { return (this.ok = true); diff --git a/Tokenization/webapp/tests/public/basic.cjs b/Tokenization/webapp/tests/public/basic.cjs deleted file mode 100644 index 04ca5d441..000000000 --- a/Tokenization/webapp/tests/public/basic.cjs +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2019-2020 CERN and copyright holders of ALICE O2. - * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. - * All rights not expressly granted are reserved. - * - * This software is distributed under the terms of the GNU General Public - * License v3 (GPL Version 3), copied verbatim in the file "COPYING". - * - * In applying this license CERN does not waive the privileges and immunities - * granted to it by virtue of its status as an Intergovernmental Organization - * or submit itself to any jurisdiction. - */ - -const assert = require('assert'); - -describe('no-label tests', function() { - let url; - let page; - - before(async function() { - ({ helpers: { url: url }, page: page } = test); - }); - - it('header content changes with navigation', async function() { - let headerContent; - - await page.goto(url); - await page.waitForSelector('header'); - headerContent = await page.$eval('header', el => el.textContent); - assert.ok(headerContent.includes('Tokenization')); - - await page.goto(`${url }/tokens`); - await page.waitForSelector('header'); - headerContent = await page.$eval('header', el => el.textContent); - assert.ok(headerContent.includes('Tokens')); - }); -}); diff --git a/Tokenization/webapp/tests/public/layout-tests.cjs b/Tokenization/webapp/tests/public/layout-tests.cjs new file mode 100644 index 000000000..a692655dd --- /dev/null +++ b/Tokenization/webapp/tests/public/layout-tests.cjs @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const assert = require('assert'); + +describe('general tests', function() { + let url; + let page; + + before(async function() { + ({ helpers: { url: url }, page: page } = test); + }); + + it('header contains links to /tokens and /certs with correct names', async function() { + await page.goto(url); + await page.waitForSelector('header'); + + // verify link to /tokens exists and its text mentions "Token" + const tokensLinkText = await page.$eval('header a[href="/tokens"]', el => el.textContent || ''); + const tokensLinkHref = await page.$eval('header a[href="/tokens"]', el => el.getAttribute('href')); + assert.strictEqual(tokensLinkHref, '/tokens'); + assert.ok(/Token/i.test(tokensLinkText), `tokens link text should include "Token", got "${tokensLinkText}"`); + + // verify link to /certs exists and its text mentions "Cert" (covers "Certs" or "Certificates") + const certsLinkText = await page.$eval('header a[href="/certs"]', el => el.textContent || ''); + const certsLinkHref = await page.$eval('header a[href="/certs"]', el => el.getAttribute('href')); + assert.strictEqual(certsLinkHref, '/certs'); + assert.ok(/Certificates/i.test(certsLinkText), `certs link text should include "Cert", got "${certsLinkText}"`); + }); + it('/tokens route displays token table and creation form', async function() { + await page.goto(`${url}/tokens`); + + await page.waitForSelector('#content'); + const pageContent = await page.$eval('#content', el => el.textContent || ''); + assert.ok(pageContent.includes('Create Token'), 'Token Creation form should be present'); + + // wait for table rows populated by the API instead of an arbitrary timeout + await page.waitForSelector('table thead'); + await page.waitForSelector('table tbody tr'); // ensures API filled the table + const headers = await page.$$eval('table thead th', ths => ths.map(t => (t.textContent || '').trim())); + + const expected = ['ID', 'Service From', 'Service To', 'Expires at', 'Actions']; + const missing = expected.filter(h => !headers.includes(h)); + assert.strictEqual(missing.length, 0, `Missing table headers: ${missing.join(', ')}`); + }); +}); diff --git a/Tokenization/webapp/tests/public/tokens/helpers.cjs b/Tokenization/webapp/tests/public/tokens/helpers.cjs new file mode 100644 index 000000000..235012607 --- /dev/null +++ b/Tokenization/webapp/tests/public/tokens/helpers.cjs @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +async function selectReactOption(reactSelect, optionIndex) { + await reactSelect.click(); + await setTimeout(() => {}, 100); + return reactSelect.$(`ul li:nth-child(${optionIndex})`); +} + +async function fillNumberInput(inputElement, number) { + return inputElement.type(number.toString()); +} + +async function fillAllFormFields(page, reactSelect1, reactSelect2, reactSelect3, expirationInput, button) { + const opt1 = await selectReactOption(reactSelect1, 1); + await opt1.click(); + const select1Content = (await opt1.evaluate(el => el.textContent)).trim(); + + const opt2 = await selectReactOption( reactSelect2, 2); + await opt2.click(); + const select2Content = (await opt2.evaluate(el => el.textContent)).trim(); + + const opt3 = await selectReactOption(reactSelect3, 1); + await opt3.click(); + const select3Content = (await opt3.evaluate(el => el.textContent)).trim(); + + const filledNumber = 10; + await fillNumberInput(expirationInput, filledNumber); + await button.click(); + + const dialogHandle = await page.waitForSelector('.modal'); + const dialogContent = (await dialogHandle.evaluate(el => el.textContent)).trim(); + + return { + dialogHandle, + dialogContent, + select1Content, + select2Content, + select3Content, + filledNumber, + }; +} + +module.exports = { + selectReactOption, + fillNumberInput, + fillAllFormFields, +}; diff --git a/Tokenization/webapp/tests/public/tokens/token-creation-err.cjs b/Tokenization/webapp/tests/public/tokens/token-creation-err.cjs new file mode 100644 index 000000000..6ae373fd8 --- /dev/null +++ b/Tokenization/webapp/tests/public/tokens/token-creation-err.cjs @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const assert = require('assert'); +const { selectReactOption, fillNumberInput, fillAllFormFields } = require('./helpers.cjs'); + +describe('token creation unsuccessful', function() { + let url; + let page; + + before(async function() { + ({ page, helpers: { url } } = test); + }); + + beforeEach(async function() { + await page.goto(`${url}/tokens/new`); + + const [ + reactSelect1, + reactSelect2, + reactSelect3, + expirationInput, + button, + ] = await Promise.all([ + page.waitForSelector('#first-service-select'), + page.waitForSelector('#second-service-select'), + page.waitForSelector('#http-select-methods'), + page.waitForSelector('.my-input > input[type="number"]'), + page.waitForSelector('button[type="submit"]'), + ]); + + this.reactSelect1 = reactSelect1; + this.reactSelect2 = reactSelect2; + this.reactSelect3 = reactSelect3; + this.expirationInput = expirationInput; + this.button = button; + }); + + it('Not filling form shows error alert', async function() { + const n_alert = await page.$('.alert'); + assert.ok(!n_alert); // alert is not present before submit + + await this.button.click(); + const alert = await page.waitForSelector('.alert'); + assert.ok(alert); // alert is shown after submit + }); + + describe('filling partially the form', function() { + it('error alert shows exp-time and HTTP methods are missing', async function() { + const opt1 = await selectReactOption(this.reactSelect1, 1); + await opt1.click(); + const opt2 = await selectReactOption(this.reactSelect2, 2); + await opt2.click(); + + await this.button.click(); + const alert = await page.waitForSelector('.alert'); + const alertContent = await alert.evaluate(el => el.textContent); + assert.ok(alertContent.includes('Expiration time', 'HTTP methods')); + }); + + it('error alert shows first service and HTTP methods are missing', async function() { + const opt2 = await selectReactOption(this.reactSelect2, 2); + await opt2.click(); + await fillNumberInput(this.expirationInput, 10); + + await this.button.click(); + const alert = await page.waitForSelector('.alert'); + const alertContent = await alert.evaluate(el => el.textContent); + assert.ok(alertContent.includes('First service', 'HTTP methods')); + }); + + it('error alert shows First service, Second service and Exp-time are missing', async function() { + const opt3 = await selectReactOption(this.reactSelect3, 1); + await opt3.click(); + + await this.button.click(); + const alert = await page.waitForSelector('.alert'); + const alertContent = await alert.evaluate(el => el.textContent); + assert.ok( + alertContent.includes('First service', 'Second service', 'Expiration time'), + ); + }); + }); + + it('filling the form correctly shows proper success message on modal window', async function() { + const { dialogContent, select1Content, select2Content, select3Content, filledNumber } = + await fillAllFormFields(page, this.reactSelect1, this.reactSelect2, this.reactSelect3, this.expirationInput, this.button); + + assert.ok(dialogContent.includes('Confirm Token Creation')); + assert.ok(dialogContent.includes('Service from: ' + select1Content)); + assert.ok(dialogContent.includes('Service to: ' + select2Content)); + assert.ok(dialogContent.includes(select3Content)); + assert.ok(dialogContent.includes('Expiration time: ' + filledNumber.toString() + ' hours')); + }); + + it('no auth error shows alert', async function() { + const { dialogHandle } = await fillAllFormFields( + page, this.reactSelect1, this.reactSelect2, this.reactSelect3, this.expirationInput, this.button, + ); + const confirmButton = await dialogHandle.$('button:nth-child(2)'); + await confirmButton.click(); + + const alert = await page.waitForSelector('.alert'); + const alertContent = await alert.evaluate(el => el.textContent); + assert.ok(alertContent.includes('Authorization error')); + }); +}); diff --git a/Tokenization/webapp/tests/public/tokens/token-deletion-err.cjs b/Tokenization/webapp/tests/public/tokens/token-deletion-err.cjs new file mode 100644 index 000000000..ac07adab0 --- /dev/null +++ b/Tokenization/webapp/tests/public/tokens/token-deletion-err.cjs @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const assert = require('assert'); + +describe('token deletion unsuccessful', function() { + let url; + let page; + + before(async function() { + ({ page, helpers: { url } } = test); + }); + + beforeEach(async function() { + await page.goto(`${url}/tokens`); // no admin access + await page.waitForSelector('table'); // wait for API to populate the table + + const _trs = await page.$$('table tbody tr'); + const trs = []; + for (const trEl of _trs) { + const id = await trEl.$eval('td:nth-child(1)', el => (el.textContent || '').trim()); + const deleteBtn = await trEl.$('button.btn-sm.bg-danger') || await trEl.$('button.bg-danger') || null; + trs.push({ tr: trEl, id, deleteBtn }); // collecting relevant info + } + + this.trs = trs; + }); + + // parametrized test for first and third row + const rowsToTest = [ + { index: 0, description: 'first row' }, + { index: 2, description: 'third row' }, + ]; + + rowsToTest.forEach(({ index, description }) => { + it(`correct id displayed in deletion confirmation modal for ${description}`, async function() { + const row = this.trs[index]; + await row.deleteBtn.click(); + + const modalContent = await page.$eval('.modal', el => el.textContent || ''); + const modalClass = await page.$eval('.modal-overlay', el => el.className); + assert.ok (modalClass.includes('d-block'), 'confirmation modal should be shown after clicking delete button'); + assert.ok(modalContent.includes(`id: ${row.id}?`), 'confirmation modal should mention the correct token ID'); + + const closeBtn = await page.$('button:nth-child(1)'); // close button + await closeBtn.click(); + }); + }); + + it('deletion shows auth error alert', async function() { + const row = this.trs[0]; // first row + await row.deleteBtn.click(); + + const confirmBtn = await page.$('button.btn-danger'); // confirm deletion button + await confirmBtn.click(); + + const alert = await page.waitForSelector('.alert'); + const alertClass = await alert.evaluate(el => el.className); + const alertContent = await alert.evaluate(el => el.textContent); + assert.ok(alertClass.includes('bg-danger'), 'error alert should be shown after failed deletion'); + assert.ok(alertContent.includes('Authorization error'), 'error alert should mention authorization issue'); + }); +}); diff --git a/Tokenization/webapp/tests/public/tokens/token-happy-path.cjs b/Tokenization/webapp/tests/public/tokens/token-happy-path.cjs new file mode 100644 index 000000000..f1bbc6e7b --- /dev/null +++ b/Tokenization/webapp/tests/public/tokens/token-happy-path.cjs @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const assert = require('assert'); +const { fillAllFormFields } = require('./helpers.cjs'); + +describe('token creation successful', function() { + let url; + let page; + + before(async function() { + ({ page, helpers: { url } } = test); + }); + + it('happy path: token creation successful', async function() { + await page.goto(`${url}/tokens/new?access=admin`); // admin access added + setTimeout(() => {}, 1000); + const { dialogHandle } = await fillAllFormFields( + page, + await page.waitForSelector('#first-service-select'), + await page.waitForSelector('#second-service-select'), + await page.waitForSelector('#http-select-methods'), + await page.waitForSelector('.my-input > input[type="number"]'), + await page.waitForSelector('button[type="submit"]'), + ); + const btn = await dialogHandle.$('.btn-success'); // accept button + + // TODO: mock/fake the API and verify the request payload here + await btn.click(); + const alert = await page.waitForSelector('.alert'); + const className = await alert.evaluate(el => el.className); + assert.ok(className.includes('bg-success'), 'success alert should be shown after creating token'); + }); + + it('happy path: token deletion successful', async function() { + await page.goto(`${url}/tokens?access=admin`); // admin access added + await page.waitForSelector('table tbody tr'); + + const _trs = await page.$$('table tbody tr'); + + const trs = []; + for (const trEl of _trs) { + const id = await trEl.$eval('td:nth-child(1)', el => (el.textContent || '').trim()); + const deleteBtn = await trEl.$('button.btn-sm.bg-danger') || await trEl.$('button.bg-danger') || null; + trs.push({ tr: trEl, id, deleteBtn }); // collecting relevant info + } + + const row = trs[1]; // second row + assert.ok(row, 'expected at least two table rows'); + assert.ok(row.deleteBtn, `delete button not found in row with id ${row.id}`); + + await row.deleteBtn.click(); + + const modal = await page.waitForSelector('.modal-overlay'); + const className = await page.$eval('.modal-overlay', el => el.className); + const content = await page.$eval('.modal-overlay', el => el.textContent || ''); + assert.ok(className.includes('d-block'), 'confirmation modal should be shown after clicking delete button'); + assert.ok(content.includes(`id: ${row.id}?`), 'confirmation modal should mention the correct token ID'); + + const confirmBtn = await modal.$('button.btn-danger'); + assert.ok(confirmBtn, 'confirm button not found in modal'); + await confirmBtn.click(); + + // TODO: mock/fake the API and verify the deletion request here + const alert = await page.waitForSelector('.alert'); + const alertClass = await alert.evaluate(el => el.className); + assert.ok(alertClass.includes('bg-success'), 'success alert should be shown after deleting token'); + + }); +});