Skip to content

Commit

Permalink
Item List Input component (#1089)
Browse files Browse the repository at this point in the history
* Add x-mark-icon

* ItemList component

* Move `ItemRow` to a separate file

* Fix svelte ci failure

* Use <for> control flow

* Refactor type and rename onChange handlers to handle...

* Tweaks for mitosis parsing

* Use ItemList component

* Remove array parsing for redirectUrl

* Make ItemList handler generic for any field, fix around mitosis gotchas

* Use ItemList component elsewhere

* Update button text

* Disable delete button for first item

* Validate duplicate entries

* Pass errorCallback

* Label support, Display duplicate entry error inline, style tweaks, disable delete if only 1 item remains

* Style error text

* Tweak prop name

* Style fixes and use shared Button component

* Pass label, type and fix hint text

* Style tweaks for delete button

* Warning triangle plus style tweaks for error message

* Adjust font-size

* Export `ItemList` for external use
  • Loading branch information
niwsa committed Apr 1, 2024
1 parent 1f039f6 commit 5d41fc0
Show file tree
Hide file tree
Showing 12 changed files with 281 additions and 67 deletions.
9 changes: 9 additions & 0 deletions src/shared/icons/XMarkIcon.lite.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { SVGProps } from '../types';

export default function XMarkIcon(props: { svgAttrs?: SVGProps }) {
return (
<svg fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='currentColor' {...props.svgAttrs}>
<path stroke-linecap='round' stroke-linejoin='round' d='M6 18 18 6M6 6l12 12' />
</svg>
);
}
1 change: 1 addition & 0 deletions src/shared/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as ClipboardButton } from './ClipboardButton/index.lite';
export { default as InputWithCopyButton } from './inputs/InputWithCopyButton/index.lite';
export { default as Themer } from './Themer/index.lite';
export { default as ItemList } from './inputs/ItemList/index.lite';
39 changes: 39 additions & 0 deletions src/shared/inputs/ItemList/ItemRow.lite.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import XMarkIcon from '../../icons/XMarkIcon.lite';
import styles from '../index.module.css';
import itemStyles from './index.module.css';

type ItemRowProps = {
inputType: 'text' | 'url' | 'number' | 'password';
item: string;
index: number;
handleItemUpdate: (newItem: string, index: number) => void;
handleItemDelete: (index: number) => void;
handleBlur: (index: number) => void;
isDuplicateItem?: boolean;
disableDelete?: boolean;
disabled?: boolean;
classNames: { input: string };
};

export default function ItemRow(props: ItemRowProps) {
return (
<div class={itemStyles.row}>
<input
type={props.inputType || 'text'}
class={`${props.classNames.input} ${styles['input-sm']} ${itemStyles['input']}`}
name='item'
value={props.item}
onChange={(event) => props.handleItemUpdate(event.target.value, props.index)}
onBlur={(event) => props.handleBlur(props.index)}
required
disabled={props.disabled}
/>
<button
type='button'
onClick={(event) => props.handleItemDelete(props.index)}
disabled={props.disableDelete}>
<XMarkIcon svgAttrs={{ class: itemStyles['svg'] }} />
</button>
</div>
);
}
112 changes: 112 additions & 0 deletions src/shared/inputs/ItemList/index.lite.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { For, Show, useStore } from '@builder.io/mitosis';
import ItemRow from './ItemRow.lite';
import styles from '../index.module.css';
import listStyles from './index.module.css';
import cssClassAssembler from '../../../sso/utils/cssClassAssembler';
import Button from '../../Button/index.lite';
import ExclamationTriangle from '../../icons/ExclamationTriangle.lite';

type ItemListProps = {
label: string;
inputType: 'text' | 'url' | 'number' | 'password';
classNames?: { label?: string; input?: string };
currentlist: string | string[];
fieldName: string;
handleItemListUpdate: (fieldName: string, newList: string[]) => void;
};

export default function ItemList(props: ItemListProps) {
const state = useStore({
duplicateEntryIndex: undefined as undefined | number,
get list() {
return Array.isArray(props.currentlist) ? props.currentlist : [props.currentlist];
},
addAnother: () => {
props.handleItemListUpdate(props.fieldName, [...state.list, '']);
},
handleItemUpdate: (newItem: string, index: number) => {
const newList = [...state.list];
newList[index] = newItem;
props.handleItemListUpdate(props.fieldName, newList);
},
checkDuplicates(index: number) {
const _item = state.list[index];
// search backwards
const _firstIndex = state.list.indexOf(_item);
if (_firstIndex !== index) {
state.duplicateEntryIndex = index;
return;
} else if (state.duplicateEntryIndex === index) {
state.duplicateEntryIndex = undefined;
}
// search forwards
const _nextIndex = state.list.slice(index + 1).indexOf(_item);
if (_nextIndex !== -1) {
state.duplicateEntryIndex = index;
} else if (state.duplicateEntryIndex === index) {
state.duplicateEntryIndex = undefined;
}
},
handleItemDelete: (index: number) => {
const _itemToDelete = state.list[index];
if (
state.duplicateEntryIndex !== undefined &&
(state.duplicateEntryIndex === index || _itemToDelete === state.list[state.duplicateEntryIndex])
) {
state.duplicateEntryIndex = undefined;
}
props.handleItemListUpdate(
props.fieldName,
state.list.filter((_, i) => i !== index)
);
},
get cssClass() {
return {
label: cssClassAssembler(props.classNames?.label, styles.label),
input: cssClassAssembler(props.classNames?.input, styles.input),
};
},
});

return (
<fieldset class={styles.fieldset}>
<legend class={state.cssClass.label}>{props.label}</legend>
<div class={listStyles.rowContainer}>
<For each={state.list}>
{(item, index) => (
<div key={index}>
<ItemRow
inputType={props.inputType}
item={item}
index={index}
isDuplicateItem={state.duplicateEntryIndex === index}
handleItemUpdate={state.handleItemUpdate}
handleItemDelete={state.handleItemDelete}
disableDelete={index === 0 && state.list.length === 1}
handleBlur={state.checkDuplicates}
disabled={state.duplicateEntryIndex !== undefined && state.duplicateEntryIndex !== index}
classNames={{ input: state.cssClass.input }}
/>
<Show when={state.duplicateEntryIndex === index}>
<span class={listStyles.error}>
<ExclamationTriangle svgAttrs={{ class: listStyles['svg'], 'aria-hidden': true }} />
Duplicate entries not allowed.
</span>
</Show>
</div>
)}
</For>
<div>
<Button
type='button'
variant='outline'
classNames={listStyles['add']}
onClick={(event) => state.addAnother()}
name='Add URL'
disabled={state.duplicateEntryIndex !== undefined}
/>
</div>
</div>
</fieldset>
);
}
42 changes: 42 additions & 0 deletions src/shared/inputs/ItemList/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
.rowContainer {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 0.5rem;
}

.error {
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
line-height: 0.5rem;
color: #ef4444;
}

.row {
display: flex;
align-items: center;
gap: 0.75rem;
}

.input {
flex: 1;
font-size: 0.875rem !important;
}

.add {
height: 2rem;
}

.svg {
height: 1.25rem;
width: 1.25rem;
color: #ef4444;
}

:disabled .svg {
color: grey;
cursor: not-allowed;
}
8 changes: 8 additions & 0 deletions src/shared/inputs/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,11 @@
display: flex;
justify-content: space-between;
}

.fieldset {
border: 0;
}

.input-sm {
height: 2rem !important;
}
32 changes: 16 additions & 16 deletions src/sso/connections/CreateConnection/oidc/index.lite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import Spacer from '../../../../shared/Spacer/index.lite';
import Separator from '../../../../shared/Separator/index.lite';
import Anchor from '../../../../shared/Anchor/index.lite';
import InputField from '../../../../shared/inputs/InputField/index.lite';
import TextArea from '../../../../shared/inputs/TextArea/index.lite';
import SecretInputFormControl from '../../../../shared/inputs/SecretInputFormControl/index.lite';
import Select from '../../../../shared/Select/index.lite';
import ItemList from '../../../../shared/inputs/ItemList/index.lite';

const DEFAULT_VALUES = {
variant: 'basic',
Expand All @@ -23,7 +23,7 @@ const INITIAL_VALUES = {
description: '',
tenant: '',
product: '',
redirectUrl: '',
redirectUrl: [''],
defaultRedirectUrl: '',
oidcClientSecret: '',
oidcClientId: '',
Expand Down Expand Up @@ -53,10 +53,13 @@ export default function CreateOIDCConnection(props: CreateConnectionProps) {
const targetValue = (event.currentTarget as HTMLInputElement | HTMLTextAreaElement)?.value as Values;
state.oidcConnection = state.updateConnection({ [id]: targetValue });
},
handleItemListUpdate(fieldName: string, listValue: string[]) {
state.oidcConnection = state.updateConnection({ [fieldName]: listValue });
},
save(event: Event) {
event.preventDefault();

const formObj = {} as Partial<OIDCSSOConnection>;
const formObj = {} as any;
Object.entries(state.oidcConnection).map(([key, val]) => {
if (key.startsWith('oidcMetadata.')) {
if (formObj.oidcMetadata === undefined) {
Expand All @@ -67,7 +70,7 @@ export default function CreateOIDCConnection(props: CreateConnectionProps) {
// pass sortOrder only if set to non-empty string
val !== '' && (formObj[key] = +val); // convert sortOrder into number
} else {
formObj[key as keyof Omit<OIDCSSOConnection, 'oidcMetadata'>] = val as string;
formObj[key] = val;
}
});
state.isSaving = true;
Expand Down Expand Up @@ -253,20 +256,17 @@ export default function CreateOIDCConnection(props: CreateConnectionProps) {
<Spacer y={6} />
</Show>
<Show when={!state.isExcluded('redirectUrl')}>
<TextArea
label='Allowed redirect URLs (newline separated)'
id='redirectUrl'
classNames={state.classes.textarea}
required
readOnly={state.isReadOnly('redirectUrl')}
aria-describedby='redirectUrl-hint'
placeholder='http://localhost:3366'
value={state.oidcConnection.redirectUrl}
handleInputChange={state.handleChange}
<ItemList
inputType='url'
label='Allowed redirect URLs'
currentlist={state.oidcConnection.redirectUrl}
fieldName='redirectUrl'
handleItemListUpdate={state.handleItemListUpdate}
classNames={state.classes.inputField}
/>
<div id='redirectUrl-hint' class={defaultClasses.hint}>
URL to redirect the user to after login. You can specify multiple URLs by separating them with a
new line.
URL(s) to redirect the user to after login. Only the URLs in this list are allowed in the OAuth
flow.
</div>
<Spacer y={6} />
</Show>
Expand Down
27 changes: 14 additions & 13 deletions src/sso/connections/CreateConnection/saml/index.lite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Checkbox from '../../../../shared/Checkbox/index.lite';
import InputField from '../../../../shared/inputs/InputField/index.lite';
import TextArea from '../../../../shared/inputs/TextArea/index.lite';
import Select from '../../../../shared/Select/index.lite';
import ItemList from '../../../../shared/inputs/ItemList/index.lite';

const DEFAULT_VALUES = {
variant: 'basic',
Expand All @@ -23,7 +24,7 @@ const INITIAL_VALUES = {
description: '',
tenant: '',
product: '',
redirectUrl: '',
redirectUrl: [''],
defaultRedirectUrl: '',
rawMetadata: '',
metadataUrl: '',
Expand Down Expand Up @@ -51,6 +52,9 @@ export default function CreateSAMLConnection(props: CreateConnectionProps) {

state.samlConnection = state.updateConnection({ [id]: targetValue });
},
handleItemListUpdate(fieldName: string, listValue: string[]) {
state.samlConnection = state.updateConnection({ [fieldName]: listValue });
},
save(event: Event) {
event.preventDefault();
state.isSaving = true;
Expand Down Expand Up @@ -237,20 +241,17 @@ export default function CreateSAMLConnection(props: CreateConnectionProps) {
<Spacer y={6} />
</Show>
<Show when={!state.isExcluded('redirectUrl')}>
<TextArea
label='Allowed redirect URLs (newline separated)'
id='redirectUrl'
classNames={state.classes.textarea}
required
readOnly={state.isReadOnly('redirectUrl')}
aria-describedby='redirectUrl-hint'
placeholder='http://localhost:3366'
value={state.samlConnection.redirectUrl}
handleInputChange={state.handleChange}
<ItemList
inputType='url'
label='Allowed redirect URLs'
currentlist={state.samlConnection.redirectUrl}
fieldName='redirectUrl'
handleItemListUpdate={state.handleItemListUpdate}
classNames={state.classes.inputField}
/>
<div id='redirectUrl-hint' class={defaultClasses.hint}>
URL to redirect the user to after login. You can specify multiple URLs by separating them with a
new line.
URL(s) to redirect the user to after login. Only the URLs in this list are allowed in the OAuth
flow.
</div>
<Spacer y={6} />
</Show>
Expand Down
Loading

0 comments on commit 5d41fc0

Please sign in to comment.