react-icons/md#MdAddCircle TypeScript Examples
The following examples show how to use
react-icons/md#MdAddCircle.
You can vote up the ones you like or vote down the ones you don't like,
and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: Modal.tsx From hub with Apache License 2.0 | 5 votes |
OrganizationModal = (props: Props) => {
const form = useRef<HTMLFormElement>(null);
const [isSending, setIsSending] = useState(false);
const [apiError, setApiError] = useState<null | string>(null);
const onCloseModal = () => {
props.onClose();
};
const submitForm = () => {
if (form.current) {
form.current.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
};
return (
<Modal
header={
<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>
{isUndefined(props.organization) ? <>Add organization</> : <>Update organization</>}
</div>
}
open={props.open}
modalClassName={styles.modal}
closeButton={
<button
className="btn btn-sm btn-outline-secondary"
type="button"
disabled={isSending}
onClick={submitForm}
aria-label={`${isUndefined(props.organization) ? 'Add' : 'Update'} organization`}
>
{isSending ? (
<>
<span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
<span className="ms-2">
{isUndefined(props.organization) ? <>Adding organization</> : <>Updating organization</>}
</span>
</>
) : (
<div className="d-flex flex-row align-items-center text-uppercase">
{isUndefined(props.organization) ? (
<>
<MdAddCircle className="me-2" />
<div>Add</div>
</>
) : (
<>
<FaPencilAlt className="me-2" />
<div>Update</div>
</>
)}
</div>
)}
</button>
}
onClose={onCloseModal}
error={apiError}
cleanError={() => setApiError(null)}
>
<div className="w-100">
<OrganizationForm
ref={form}
organization={props.organization}
onSuccess={() => {
if (!isUndefined(props.onSuccess)) {
props.onSuccess();
}
onCloseModal();
}}
setIsSending={setIsSending}
onAuthError={props.onAuthError}
setApiError={setApiError}
/>
</div>
</Modal>
);
}
Example #2
Source File: Modal.tsx From hub with Apache License 2.0 | 4 votes |
MemberModal = (props: Props) => {
const { ctx } = useContext(AppCtx);
const aliasInput = useRef<RefInputField>(null);
const form = useRef<HTMLFormElement>(null);
const [isSending, setIsSending] = useState(false);
const [isValidated, setIsValidated] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
// Clean API error when form is focused after validation
const cleanApiError = () => {
if (!isNull(apiError)) {
setApiError(null);
}
};
const onCloseModal = () => {
props.onClose();
};
async function handleOrganizationMember(alias: string) {
try {
await API.addOrganizationMember(ctx.prefs.controlPanel.selectedOrg!, alias);
if (!isUndefined(props.onSuccess)) {
props.onSuccess();
}
setIsSending(false);
onCloseModal();
} catch (err: any) {
setIsSending(false);
if (err.kind !== ErrorKind.Unauthorized) {
let errorMessage = 'An error occurred adding the new member, please try again later.';
if (err.kind === ErrorKind.Forbidden) {
errorMessage = 'You do not have permissions to add a new member to the organization.';
}
setApiError(errorMessage);
} else {
props.onAuthError();
}
}
}
const submitForm = () => {
cleanApiError();
setIsSending(true);
if (form.current) {
validateForm(form.current).then((validation: FormValidation) => {
if (validation.isValid && !isUndefined(validation.alias)) {
handleOrganizationMember(validation.alias);
} else {
setIsSending(false);
}
});
}
};
const validateForm = async (form: HTMLFormElement): Promise<FormValidation> => {
let alias: undefined | string;
return aliasInput.current!.checkIsValid().then((isValid: boolean) => {
if (isValid) {
const formData = new FormData(form);
alias = formData.get('alias') as string;
}
setIsValidated(true);
return { isValid: isValid, alias };
});
};
const handleOnReturnKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter' && !isNull(form)) {
event.preventDefault();
event.stopPropagation();
submitForm();
}
};
const getMembers = (): string[] => {
let members: string[] = [];
if (!isUndefined(props.membersList)) {
members = props.membersList.map((member: Member) => member.alias);
}
return members;
};
return (
<Modal
header={<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>Add member</div>}
open={props.open}
modalClassName={styles.modal}
closeButton={
<button
className="btn btn-sm btn-outline-secondary"
type="button"
disabled={isSending}
onClick={submitForm}
aria-label="Invite member"
>
{isSending ? (
<>
<span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
<span className="ms-2">Inviting member</span>
</>
) : (
<div className="d-flex flex-row align-items-center text-uppercase">
<MdAddCircle className="me-2" />
<div>Invite</div>
</div>
)}
</button>
}
onClose={onCloseModal}
error={apiError}
cleanError={cleanApiError}
>
<div className="w-100">
<form
data-testid="membersForm"
ref={form}
className={classnames('w-100', { 'needs-validation': !isValidated }, { 'was-validated': isValidated })}
onFocus={cleanApiError}
autoComplete="on"
noValidate
>
<InputField
ref={aliasInput}
type="text"
label="Username"
labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
name="alias"
value=""
invalidText={{
default: 'This field is required',
customError: 'User not found',
excluded: 'This user is already a member of the organization',
}}
checkAvailability={{
isAvailable: false,
resourceKind: ResourceKind.userAlias,
excluded: [],
}}
excludedValues={getMembers()}
autoComplete="off"
onKeyDown={handleOnReturnKeyDown}
additionalInfo={
<small className="text-muted text-break mt-1">
<p>The user must be previously registered</p>
</small>
}
required
/>
</form>
</div>
</Modal>
);
}
Example #3
Source File: index.tsx From hub with Apache License 2.0 | 4 votes |
MembersSection = (props: Props) => {
const history = useHistory();
const { ctx } = useContext(AppCtx);
const [isGettingMembers, setIsGettingMembers] = useState(false);
const [members, setMembers] = useState<Member[] | undefined>(undefined);
const [modalMemberOpen, setModalMemberOpen] = useState(false);
const [confirmedMembersNumber, setConfirmedMembersNumber] = useState<number>(0);
const [activeOrg, setActiveOrg] = useState<undefined | string>(ctx.prefs.controlPanel.selectedOrg);
const [apiError, setApiError] = useState<null | string>(null);
const [activePage, setActivePage] = useState<number>(props.activePage ? parseInt(props.activePage) : 1);
const calculateOffset = (pageNumber?: number): number => {
return DEFAULT_LIMIT * ((pageNumber || activePage) - 1);
};
const [offset, setOffset] = useState<number>(calculateOffset());
const [total, setTotal] = useState<number | undefined>(undefined);
const onPageNumberChange = (pageNumber: number): void => {
setOffset(calculateOffset(pageNumber));
setActivePage(pageNumber);
};
const updatePageNumber = () => {
history.replace({
search: `?page=${activePage}`,
});
};
const getConfirmedMembersNumber = (members: Member[]): number => {
const confirmedMembers = members.filter((member: Member) => member.confirmed);
return confirmedMembers.length;
};
async function fetchMembers() {
try {
setIsGettingMembers(true);
const data = await API.getOrganizationMembers(
{
limit: DEFAULT_LIMIT,
offset: offset,
},
activeOrg!
);
const total = parseInt(data.paginationTotalCount);
if (total > 0 && data.items.length === 0) {
onPageNumberChange(1);
} else {
setMembers(data.items);
setTotal(total);
setConfirmedMembersNumber(getConfirmedMembersNumber(data.items));
}
updatePageNumber();
setApiError(null);
setIsGettingMembers(false);
} catch (err: any) {
setIsGettingMembers(false);
if (err.kind !== ErrorKind.Unauthorized) {
setMembers([]);
setApiError('An error occurred getting the organization members, please try again later.');
} else {
props.onAuthError();
}
}
}
useEffect(() => {
if (props.activePage && activePage !== parseInt(props.activePage)) {
fetchMembers();
}
}, [activePage]); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (!isUndefined(members)) {
if (activePage === 1) {
// fetchMembers is forced when context changes
fetchMembers();
} else {
// when current page is different to 1, to update page number fetchMembers is called
onPageNumberChange(1);
}
}
}, [activeOrg]); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (activeOrg !== ctx.prefs.controlPanel.selectedOrg) {
setActiveOrg(ctx.prefs.controlPanel.selectedOrg);
}
}, [ctx.prefs.controlPanel.selectedOrg]); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
fetchMembers();
}, []); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<main
role="main"
className="px-xs-0 px-sm-3 px-lg-0 d-flex flex-column flex-md-row justify-content-between my-md-4"
>
<div className="flex-grow-1 w-100 mb-4">
<div>
<div className="d-flex flex-row align-items-center justify-content-between pb-2 border-bottom">
<div className={`h3 pb-0 ${styles.title}`}>Members</div>
<div>
<ActionBtn
className={`btn btn-outline-secondary btn-sm text-uppercase ${styles.btnAction}`}
contentClassName="justify-content-center"
onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setModalMemberOpen(true);
}}
action={AuthorizerAction.AddOrganizationMember}
label="Open invite member modal"
>
<div className="d-flex flex-row align-items-center">
<MdAdd className="d-inline d-md-none" />
<MdAddCircle className="d-none d-md-inline me-2" />
<span className="d-none d-md-inline">Invite</span>
</div>
</ActionBtn>
</div>
</div>
</div>
{(isGettingMembers || isUndefined(members)) && <Loading />}
<div className="mt-5">
{!isUndefined(members) && (
<>
{members.length === 0 ? (
<NoData issuesLinkVisible={!isNull(apiError)}>
{isNull(apiError) ? (
<>
<p className="h6 my-4 lh-base">Do you want to add a member?</p>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setModalMemberOpen(true)}
aria-label="Open modal"
>
<div className="d-flex flex-row align-items-center text-uppercase">
<MdAddCircle className="me-2" />
<span>Add member</span>
</div>
</button>
</>
) : (
<>{apiError}</>
)}
</NoData>
) : (
<>
<div className="row mt-4 mt-md-5 gx-0 gx-xxl-4">
{members.map((member: Member) => (
<MemberCard
key={`member_${member.alias}`}
member={member}
onAuthError={props.onAuthError}
onSuccess={fetchMembers}
membersNumber={confirmedMembersNumber}
/>
))}
</div>
{!isUndefined(total) && (
<div className="mx-auto">
<Pagination
limit={DEFAULT_LIMIT}
offset={offset}
total={total}
active={activePage}
className="my-5"
onChange={onPageNumberChange}
/>
</div>
)}
</>
)}
</>
)}
</div>
</div>
{modalMemberOpen && (
<MemberModal
open={modalMemberOpen}
membersList={members}
onSuccess={fetchMembers}
onAuthError={props.onAuthError}
onClose={() => setModalMemberOpen(false)}
/>
)}
</main>
);
}
Example #4
Source File: index.tsx From hub with Apache License 2.0 | 4 votes |
OrganizationsSection = (props: Props) => {
const history = useHistory();
const [isLoading, setIsLoading] = useState(false);
const [modalStatus, setModalStatus] = useState<ModalStatus>({
open: false,
});
const [organizations, setOrganizations] = useState<Organization[] | undefined>(undefined);
const [apiError, setApiError] = useState<null | string>(null);
const [activePage, setActivePage] = useState<number>(props.activePage ? parseInt(props.activePage) : 1);
const calculateOffset = (pageNumber?: number): number => {
return DEFAULT_LIMIT * ((pageNumber || activePage) - 1);
};
const [offset, setOffset] = useState<number>(calculateOffset());
const [total, setTotal] = useState<number | undefined>(undefined);
const onPageNumberChange = (pageNumber: number): void => {
setOffset(calculateOffset(pageNumber));
setActivePage(pageNumber);
};
const updatePageNumber = () => {
history.replace({
search: `?page=${activePage}`,
});
};
async function fetchOrganizations() {
try {
setIsLoading(true);
const data = await API.getUserOrganizations({
limit: DEFAULT_LIMIT,
offset: offset,
});
const total = parseInt(data.paginationTotalCount);
if (total > 0 && data.items.length === 0) {
onPageNumberChange(1);
} else {
setOrganizations(data.items);
setTotal(total);
}
updatePageNumber();
setApiError(null);
setIsLoading(false);
} catch (err: any) {
setIsLoading(false);
if (err.kind !== ErrorKind.Unauthorized) {
setOrganizations([]);
setApiError('An error occurred getting your organizations, please try again later.');
} else {
props.onAuthError();
}
}
}
useEffect(() => {
fetchOrganizations();
}, []); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (props.activePage && activePage !== parseInt(props.activePage)) {
fetchOrganizations();
}
}, [activePage]); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<main
role="main"
className="px-xs-0 px-sm-3 px-lg-0 d-flex flex-column flex-md-row justify-content-between my-md-4"
>
<div className="flex-grow-1 w-100">
<div>
<div className="d-flex flex-row align-items-center justify-content-between pb-2 border-bottom">
<div className={`h3 pb-0 ${styles.title}`}>Organizations</div>
<div>
<button
className={`btn btn-outline-secondary btn-sm text-uppercase ${styles.btnAction}`}
onClick={() => setModalStatus({ open: true })}
aria-label="Open modal"
>
<div className="d-flex flex-row align-items-center justify-content-center">
<MdAdd className="d-inline d-md-none" />
<MdAddCircle className="d-none d-md-inline me-2" />
<span className="d-none d-md-inline">Add</span>
</div>
</button>
</div>
</div>
{(isLoading || isUndefined(organizations)) && <Loading />}
{!isUndefined(organizations) && (
<>
{organizations.length === 0 ? (
<NoData issuesLinkVisible={!isNull(apiError)}>
{isNull(apiError) ? (
<>
<p className="h6 my-4 lh-base">Do you need to create a organization?</p>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setModalStatus({ open: true })}
aria-label="Open modal for adding first organization"
>
<div className="d-flex flex-row align-items-center text-uppercase">
<MdAddCircle className="me-2" />
<span>Add new organization</span>
</div>
</button>
</>
) : (
<>{apiError}</>
)}
</NoData>
) : (
<>
<div className="row mt-4 mt-md-5 gx-0 gx-xxl-4">
{organizations.map((org: Organization, index: number) => (
<OrganizationCard
key={`org_${org.name}_${index}`}
organization={org}
onAuthError={props.onAuthError}
onSuccess={fetchOrganizations}
/>
))}
</div>
{!isUndefined(total) && (
<div className="mx-auto">
<Pagination
limit={DEFAULT_LIMIT}
offset={offset}
total={total}
active={activePage}
className="my-5"
onChange={onPageNumberChange}
/>
</div>
)}
</>
)}
</>
)}
</div>
</div>
<OrganizationModal
{...modalStatus}
onSuccess={fetchOrganizations}
onAuthError={props.onAuthError}
onClose={() => setModalStatus({ open: false })}
/>
</main>
);
}
Example #5
Source File: Modal.tsx From hub with Apache License 2.0 | 4 votes |
RepositoryModal = (props: Props) => {
const { ctx } = useContext(AppCtx);
const form = useRef<HTMLFormElement>(null);
const nameInput = useRef<RefInputField>(null);
const urlInput = useRef<RefInputField>(null);
const [isSending, setIsSending] = useState(false);
const [isValidated, setIsValidated] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const organizationName = ctx.prefs.controlPanel.selectedOrg;
const [isDisabled, setIsDisabled] = useState<boolean>(props.repository ? props.repository.disabled! : false);
const [isScannerDisabled, setIsScannerDisabled] = useState<boolean>(
props.repository ? props.repository.scannerDisabled! : false
);
const [visibleDisabledConfirmation, setVisibleDisabledConfirmation] = useState<boolean>(false);
const [selectedKind, setSelectedKind] = useState<RepositoryKind>(
isUndefined(props.repository) ? DEFAULT_SELECTED_REPOSITORY_KIND : props.repository.kind
);
const [isValidInput, setIsValidInput] = useState<boolean>(false);
const [urlContainsTreeTxt, setUrlContainsTreeTxt] = useState<boolean>(false);
const [resetFields, setResetFields] = useState<boolean>(false);
const [authUser, setAuthUser] = useState<string | null>(props.repository ? props.repository.authUser || null : null);
const [authPass, setAuthPass] = useState<string | null>(props.repository ? props.repository.authPass || null : null);
const prepareTags = (): ContainerTag[] => {
if (props.repository) {
if (props.repository.data && props.repository.data.tags && !isEmpty(props.repository.data.tags)) {
return props.repository.data.tags.map((tag: ContainerTag) => {
return { ...tag, id: nanoid() };
});
} else {
return [];
}
} else {
// By default, we add mutable tag latest for new container images
return [{ name: 'latest', mutable: true, id: nanoid() }];
}
};
const [containerTags, setContainerTags] = useState<ContainerTag[]>(prepareTags());
const [repeatedTagNames, setRepeatedTagNames] = useState<boolean>(false);
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setIsValidInput(e.target.value === props.repository!.name);
};
const onUrlInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setUrlContainsTreeTxt(e.target.value.includes('/tree/'));
};
const allowPrivateRepositories: boolean = getMetaTag('allowPrivateRepositories', true);
const siteName = getMetaTag('siteName');
// Clean API error when form is focused after validation
const cleanApiError = () => {
if (!isNull(apiError)) {
setApiError(null);
}
};
const onCloseModal = () => {
props.onClose();
};
async function handleRepository(repository: Repository) {
try {
if (isUndefined(props.repository)) {
await API.addRepository(repository, organizationName);
} else {
await API.updateRepository(repository, organizationName);
}
if (!isUndefined(props.onSuccess)) {
props.onSuccess();
}
setIsSending(false);
onCloseModal();
} catch (err: any) {
setIsSending(false);
if (err.kind !== ErrorKind.Unauthorized) {
let error = compoundErrorMessage(
err,
`An error occurred ${isUndefined(props.repository) ? 'adding' : 'updating'} the repository`
);
if (!isUndefined(organizationName) && err.kind === ErrorKind.Forbidden) {
error = `You do not have permissions to ${isUndefined(props.repository) ? 'add' : 'update'} the repository ${
isUndefined(props.repository) ? 'to' : 'from'
} the organization.`;
}
setApiError(error);
} else {
props.onAuthError();
}
}
}
const submitForm = () => {
cleanApiError();
setIsSending(true);
if (form.current) {
validateForm(form.current).then((validation: FormValidation) => {
if (validation.isValid && !isNull(validation.repository)) {
handleRepository(validation.repository);
} else {
setIsSending(false);
}
});
}
};
const validateForm = async (form: HTMLFormElement): Promise<FormValidation> => {
let repository: Repository | null = null;
return validateAllFields().then((isValid: boolean) => {
if (isValid) {
const formData = new FormData(form);
repository = {
kind: selectedKind,
name: !isUndefined(props.repository) ? props.repository.name : (formData.get('name') as string),
url: formData.get('url') as string,
branch: formData.get('branch') as string,
displayName: formData.get('displayName') as string,
disabled: isDisabled,
scannerDisabled: isScannerDisabled,
authUser:
!isUndefined(props.repository) &&
props.repository.private &&
!resetFields &&
selectedKind === RepositoryKind.Helm
? '='
: authUser,
authPass: !isUndefined(props.repository) && props.repository.private && !resetFields ? '=' : authPass,
};
if (selectedKind === RepositoryKind.Container) {
const cleanTags = containerTags.filter((tag: ContainerTag) => tag.name !== '');
const readyTags = cleanTags.map((tag: ContainerTag) => ({ name: tag.name, mutable: tag.mutable }));
repository.data = {
tags: readyTags,
};
}
}
setIsValidated(true);
return { isValid, repository };
});
};
const checkContainerTags = (): boolean => {
if (selectedKind !== RepositoryKind.Container) return true;
const tagNames = compact(containerTags.map((tag: ContainerTag) => tag.name));
const uniqTagNames = uniq(tagNames);
if (tagNames.length === uniqTagNames.length) {
setRepeatedTagNames(false);
return true;
} else {
setRepeatedTagNames(true);
return false;
}
};
const validateAllFields = async (): Promise<boolean> => {
return Promise.all([
nameInput.current!.checkIsValid(),
urlInput.current!.checkIsValid(),
checkContainerTags(),
]).then((res: boolean[]) => {
return every(res, (isValid: boolean) => isValid);
});
};
const handleOnReturnKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter' && !isNull(form)) {
submitForm();
}
};
const handleKindChange = (event: ChangeEvent<HTMLSelectElement>) => {
setSelectedKind(parseInt(event.target.value));
// URL is validated when value has been entered previously
if (urlInput.current!.getValue() !== '') {
urlInput.current!.checkIsValid();
}
};
const getAdditionalInfo = (): JSX.Element | undefined => {
let link: JSX.Element | undefined;
switch (selectedKind) {
case RepositoryKind.Helm:
link = (
<ExternalLink
href="/docs/topics/repositories#helm-charts-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
Helm charts repositories
</ExternalLink>
);
break;
case RepositoryKind.OLM:
link = (
<ExternalLink
href="/docs/topics/repositories#olm-operators-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
OLM operators repositories
</ExternalLink>
);
break;
case RepositoryKind.Falco:
link = (
<ExternalLink
href="/docs/topics/repositories#falco-rules-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
Falco rules repositories
</ExternalLink>
);
break;
case RepositoryKind.OPA:
link = (
<ExternalLink
href="/docs/topics/repositories#opa-policies-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
OPA policies repositories
</ExternalLink>
);
break;
case RepositoryKind.TBAction:
link = (
<ExternalLink
href="/docs/topics/repositories#tinkerbell-actions-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
Tinkerbell actions
</ExternalLink>
);
break;
case RepositoryKind.Krew:
link = (
<ExternalLink
href="/docs/topics/repositories#krew-kubectl-plugins-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
Krew kubectl plugins
</ExternalLink>
);
break;
case RepositoryKind.HelmPlugin:
link = (
<ExternalLink
href="/docs/topics/repositories#helm-plugins-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
Helm plugins
</ExternalLink>
);
break;
case RepositoryKind.TektonTask:
link = (
<ExternalLink
href="/docs/topics/repositories#tekton-tasks-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
<u>Tekton tasks</u>
</ExternalLink>
);
break;
case RepositoryKind.KedaScaler:
link = (
<ExternalLink
href="/docs/topics/repositories#keda-scalers-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
KEDA scalers
</ExternalLink>
);
break;
case RepositoryKind.CoreDNS:
link = (
<ExternalLink
href="/docs/topics/repositories#coredns-plugins-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
CoreDNS plugins
</ExternalLink>
);
break;
case RepositoryKind.Keptn:
link = (
<ExternalLink
href="/docs/topics/repositories#keptn-integrations-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
Keptn integrations
</ExternalLink>
);
break;
case RepositoryKind.TektonPipeline:
link = (
<ExternalLink
href="/docs/topics/repositories#tekton-pipelines-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
Tekton pipelines
</ExternalLink>
);
break;
case RepositoryKind.Container:
link = (
<ExternalLink
href="/docs/topics/repositories#container-images-repositories"
className="text-primary fw-bold"
label="Open documentation"
>
Container images
</ExternalLink>
);
break;
}
if (isUndefined(link)) return;
return (
<div className="text-muted text-break mt-1 mb-4">
<small>
{(() => {
switch (selectedKind) {
case RepositoryKind.Falco:
case RepositoryKind.OLM:
case RepositoryKind.OPA:
case RepositoryKind.TBAction:
case RepositoryKind.Krew:
case RepositoryKind.HelmPlugin:
case RepositoryKind.TektonTask:
case RepositoryKind.KedaScaler:
case RepositoryKind.CoreDNS:
case RepositoryKind.Keptn:
case RepositoryKind.TektonPipeline:
return (
<>
<p
className={classnames('mb-2 opacity-100', {
[styles.animatedWarning]: urlContainsTreeTxt,
})}
>
Please DO NOT include the git hosting platform specific parts, like
<span className="fw-bold">tree/branch</span>, just the path to your packages like it would show in
the filesystem.
</p>
<p className="mb-0">
For more information about the url format and the repository structure, please see the {link}{' '}
section in the{' '}
<ExternalLink
href="/docs/repositories"
className="text-primary fw-bold"
label="Open documentation"
>
repositories guide
</ExternalLink>
.
</p>
</>
);
case RepositoryKind.Container:
return (
<>
<p className="mb-3">
The url <span className="fw-bold">must</span> follow the following format:
</p>
<p className="mb-3 ms-3">
<code className={`me-2 ${styles.code}`}>oci://registry/[namespace]/repository</code> (example:{' '}
<span className="fst-italic">oci://index.docker.io/artifacthub/ah</span>)
</p>
<p>
The registry host is required, please use <code className={styles.code}>index.docker.io</code>{' '}
when referring to repositories hosted in the Docker Hub. The url should not contain any tag. For
more information please see the {link} section in the{' '}
<ExternalLink
href="/docs/repositories"
className="text-primary fw-bold"
label="Open documentation"
>
repositories guide
</ExternalLink>
.
</p>
</>
);
default:
return (
<p className="mb-0">
For more information about the url format and the repository structure, please see the {link}{' '}
section in the{' '}
<ExternalLink href="/docs/repositories" className="text-primary fw-bold" label="Open documentation">
repositories guide
</ExternalLink>
.
</p>
);
}
})()}
</small>
</div>
);
};
const getURLPattern = (): string | undefined => {
switch (selectedKind) {
case RepositoryKind.Helm:
return undefined;
case RepositoryKind.OLM:
return `(^(https://([A-Za-z0-9_.-]+)/|${OCI_PREFIX})[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/?(.*)$`;
case RepositoryKind.Container:
return `^(${OCI_PREFIX})[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/?(.*)$`;
default:
return '^(https://([A-Za-z0-9_.-]+)/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/?(.*)$';
}
};
const renderPrivateFields = (): JSX.Element => (
<>
{(() => {
switch (selectedKind) {
case RepositoryKind.Helm:
case RepositoryKind.Container:
return (
<div className="row">
<InputField
className="col-sm-12 col-md-6"
type="text"
label="Username"
name="authUser"
autoComplete="off"
value={authUser || ''}
onChange={onAuthUserChange}
/>
<InputField
className="col-sm-12 col-md-6"
type="password"
label="Password"
name="authPass"
autoComplete="new-password"
value={authPass || ''}
onChange={onAuthPassChange}
visiblePassword
/>
</div>
);
default:
return (
<div>
<InputField
type="text"
label="Authentication token"
name="authPass"
additionalInfo={
<small className="text-muted text-break mt-1">
<p className="mb-0">Authentication token used in private git based repositories.</p>
</small>
}
value={authPass || ''}
onChange={onAuthPassChange}
/>
</div>
);
}
})()}
</>
);
const resetAuthFields = () => {
setResetFields(true);
setAuthPass(null);
setAuthUser(null);
};
const onAuthUserChange = (e: ChangeEvent<HTMLInputElement>) => {
setAuthUser(e.target.value);
};
const onAuthPassChange = (e: ChangeEvent<HTMLInputElement>) => {
setAuthPass(e.target.value);
};
return (
<Modal
header={
<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>
{isUndefined(props.repository) ? (
<>Add repository</>
) : (
<>{visibleDisabledConfirmation ? 'Disable repository' : 'Update repository'}</>
)}
</div>
}
open={props.open}
modalClassName={classnames(styles.modal, { [styles.allowPrivateModal]: allowPrivateRepositories })}
closeButton={
<>
{visibleDisabledConfirmation ? (
<>
<button
type="button"
className={`btn btn-sm btn-success ${styles.btnLight}`}
onClick={() => {
setVisibleDisabledConfirmation(false);
setIsValidInput(false);
}}
aria-label="Cancel"
>
<span>I'll leave it enabled</span>
</button>
<button
type="button"
className={classnames(
'btn btn-sm ms-3',
{ 'btn-outline-secondary': !isValidInput },
{ 'btn-danger': isValidInput }
)}
onClick={(e) => {
e.preventDefault();
setIsDisabled(!isDisabled);
setVisibleDisabledConfirmation(false);
}}
disabled={!isValidInput}
aria-label="Disable repository"
>
<span>I understand, continue</span>
</button>
</>
) : (
<button
className="btn btn-sm btn-outline-secondary"
type="button"
disabled={isSending || visibleDisabledConfirmation}
onClick={submitForm}
aria-label={`${isUndefined(props.repository) ? 'Add' : 'Update'} repository`}
>
{isSending ? (
<>
<span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
<span className="ms-2">Validating repository...</span>
</>
) : (
<div className="d-flex flex-row align-items-center text-uppercase">
{isUndefined(props.repository) ? (
<>
<MdAddCircle className="me-2" />
<div>Add</div>
</>
) : (
<>
<FaPencilAlt className="me-2" />
<div>Update</div>
</>
)}
</div>
)}
</button>
)}
</>
}
onClose={onCloseModal}
error={apiError}
cleanError={cleanApiError}
>
<div className="w-100">
{visibleDisabledConfirmation ? (
<>
<div className="alert alert-warning my-4">
<span className="fw-bold text-uppercase">Important:</span> Please read this carefully.
</div>
<p>If you disable this repository all packages belonging to it will be deleted.</p>
<p>
All information related to the packages in your repository will be permanently deleted as well. This
includes packages' stars, subscriptions, webhooks, events and notifications.{' '}
<span className="fw-bold">This operation cannot be undone.</span>
</p>
<p>
You can enable back your repository at any time and the information available in the source repository
will be indexed and made available in {siteName} again.
</p>
<p>
Please type <span className="fw-bold">{props.repository!.name}</span> to confirm:
</p>
<InputField type="text" name="repoName" autoComplete="off" value="" onChange={onInputChange} />
</>
) : (
<form
data-testid="repoForm"
ref={form}
className={classnames('w-100', { 'needs-validation': !isValidated }, { 'was-validated': isValidated })}
onFocus={cleanApiError}
autoComplete="on"
noValidate
>
<div className="w-75 mb-4">
<label className={`form-label fw-bold ${styles.label}`} htmlFor="repoKind">
Kind
</label>
<select
className="form-select"
aria-label="kind-select"
name="repoKind"
value={selectedKind.toString()}
onChange={handleKindChange}
disabled={!isUndefined(props.repository)}
required
>
{REPOSITORY_KINDS.map((repoKind: RepoKindDef) => {
return (
<option key={`kind_${repoKind.label}`} value={repoKind.kind}>
{repoKind.name}
</option>
);
})}
</select>
</div>
<InputField
ref={nameInput}
type="text"
label="Name"
labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
name="name"
value={!isUndefined(props.repository) ? props.repository.name : ''}
readOnly={!isUndefined(props.repository)}
invalidText={{
default: 'This field is required',
patternMismatch: 'Only lower case letters, numbers or hyphens. Must start with a letter',
customError: 'There is another repository with this name',
}}
validateOnBlur
checkAvailability={{
isAvailable: true,
resourceKind: ResourceKind.repositoryName,
excluded: !isUndefined(props.repository) ? [props.repository.name] : [],
}}
pattern="[a-z][a-z0-9-]*"
autoComplete="off"
disabled={!isUndefined(props.repository)}
additionalInfo={
<small className="text-muted text-break mt-1">
<p className="mb-0">
This name will appear in your packages' urls and <span className="fw-bold">cannot be updated</span>{' '}
once is saved.
</p>
</small>
}
required
/>
<InputField
type="text"
label="Display name"
name="displayName"
value={
!isUndefined(props.repository) && !isNull(props.repository.displayName)
? props.repository.displayName
: ''
}
/>
<InputField
ref={urlInput}
type="url"
label="Url"
labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
name="url"
value={props.repository ? props.repository.url || '' : ''}
invalidText={{
default: 'This field is required',
typeMismatch: 'Please enter a valid url',
patternMismatch: 'Please enter a valid repository url for this repository kind',
customError: 'There is another repository using this url',
}}
onKeyDown={handleOnReturnKeyDown}
validateOnBlur
checkAvailability={{
isAvailable: true,
resourceKind: ResourceKind.repositoryURL,
excluded: props.repository ? [props.repository.url] : [],
}}
placeholder={selectedKind === RepositoryKind.Container ? OCI_PREFIX : ''}
pattern={getURLPattern()}
onChange={onUrlInputChange}
required
/>
{getAdditionalInfo()}
{selectedKind === RepositoryKind.Container && (
<TagsList
tags={containerTags}
setContainerTags={setContainerTags}
repeatedTagNames={repeatedTagNames}
setRepeatedTagNames={setRepeatedTagNames}
/>
)}
{[
RepositoryKind.Falco,
RepositoryKind.OLM,
RepositoryKind.OPA,
RepositoryKind.TBAction,
RepositoryKind.Krew,
RepositoryKind.HelmPlugin,
RepositoryKind.TektonTask,
RepositoryKind.KedaScaler,
RepositoryKind.CoreDNS,
RepositoryKind.Keptn,
RepositoryKind.TektonPipeline,
].includes(selectedKind) && (
<div>
<InputField
type="text"
label="Branch"
name="branch"
placeholder="master"
additionalInfo={
<small className="text-muted text-break mt-1">
<p className="mb-0">
Branch used in git based repositories. The <span className="fw-bold">master</span> branch is
used by default when none is provided.
</p>
</small>
}
value={!isUndefined(props.repository) && props.repository.branch ? props.repository.branch : ''}
/>
</div>
)}
{allowPrivateRepositories && (
<>
{props.repository && props.repository.private ? (
<>
{!resetFields ? (
<div className="mt-1 mb-4">
<div className={`fw-bold mb-2 ${styles.label}`}>Credentials</div>
<small>
<p className="mb-0 text-muted text-break">
This repository is private and has some credentials set. Current credentials cannot be
viewed, but you can{' '}
<button
type="button"
className={`btn btn-link btn-sm p-0 m-0 text-primary fw-bold position-relative d-inline-block ${styles.btnInline}`}
onClick={resetAuthFields}
aria-label="Reset credentials"
>
reset them
</button>
.
</p>
</small>
</div>
) : (
<>{renderPrivateFields()}</>
)}
</>
) : (
<>{renderPrivateFields()}</>
)}
</>
)}
<div className="mb-4">
<div className="form-check form-switch ps-0">
<label htmlFor="disabledRepo" className={`form-check-label fw-bold ${styles.label}`}>
Disabled
</label>
<input
id="disabledRepo"
type="checkbox"
className="form-check-input position-absolute ms-2"
role="switch"
value="true"
onChange={() => {
// Confirmation content is displayed when an existing repo is going to be disabled and it was not disabled before
if (props.repository && !isDisabled && !props.repository.disabled) {
setVisibleDisabledConfirmation(true);
} else {
setIsDisabled(!isDisabled);
}
}}
checked={isDisabled}
/>
</div>
<div className="form-text text-muted mt-2">
Use this switch to disable the repository temporarily or permanently.
</div>
</div>
{[
RepositoryKind.Helm,
RepositoryKind.Falco,
RepositoryKind.OLM,
RepositoryKind.OPA,
RepositoryKind.TBAction,
RepositoryKind.TektonTask,
RepositoryKind.KedaScaler,
RepositoryKind.CoreDNS,
RepositoryKind.Keptn,
RepositoryKind.TektonPipeline,
RepositoryKind.Container,
].includes(selectedKind) && (
<div className="mt-4 mb-3">
<div className="form-check form-switch ps-0">
<label htmlFor="scannerDisabledRepo" className={`form-check-label fw-bold ${styles.label}`}>
Security scanner disabled
</label>{' '}
<input
id="scannerDisabledRepo"
type="checkbox"
className="form-check-input position-absolute ms-2"
value="true"
role="switch"
onChange={() => setIsScannerDisabled(!isScannerDisabled)}
checked={isScannerDisabled}
/>
</div>
<div className="form-text text-muted mt-2">
Use this switch to disable the security scanning of the packages in this repository.
</div>
</div>
)}
</form>
)}
</div>
</Modal>
);
}
Example #6
Source File: index.tsx From hub with Apache License 2.0 | 4 votes |
RepositoriesSection = (props: Props) => {
const history = useHistory();
const { ctx, dispatch } = useContext(AppCtx);
const [isLoading, setIsLoading] = useState(false);
const [modalStatus, setModalStatus] = useState<ModalStatus>({
open: false,
});
const [openClaimRepo, setOpenClaimRepo] = useState<boolean>(false);
const [repositories, setRepositories] = useState<Repo[] | undefined>(undefined);
const [activeOrg, setActiveOrg] = useState<undefined | string>(ctx.prefs.controlPanel.selectedOrg);
const [apiError, setApiError] = useState<null | string>(null);
const [activePage, setActivePage] = useState<number>(props.activePage ? parseInt(props.activePage) : 1);
const calculateOffset = (pageNumber?: number): number => {
return DEFAULT_LIMIT * ((pageNumber || activePage) - 1);
};
const [offset, setOffset] = useState<number>(calculateOffset());
const [total, setTotal] = useState<number | undefined>(undefined);
const onPageNumberChange = (pageNumber: number): void => {
setOffset(calculateOffset(pageNumber));
setActivePage(pageNumber);
};
const updatePageNumber = () => {
history.replace({
search: `?page=${activePage}${props.repoName ? `&repo-name=${props.repoName}` : ''}${
props.visibleModal ? `&modal=${props.visibleModal}` : ''
}`,
});
};
async function fetchRepositories() {
try {
setIsLoading(true);
let filters: { [key: string]: string[] } = {};
if (activeOrg) {
filters.org = [activeOrg];
} else {
filters.user = [ctx.user!.alias];
}
let query: SearchQuery = {
offset: offset,
limit: DEFAULT_LIMIT,
filters: filters,
};
const data = await API.searchRepositories(query);
const total = parseInt(data.paginationTotalCount);
if (total > 0 && data.items.length === 0) {
onPageNumberChange(1);
} else {
const repos = data.items;
setRepositories(repos);
setTotal(total);
// Check if active repo logs modal is in the available repos
if (!isUndefined(props.repoName)) {
const activeRepo = repos.find((repo: Repo) => repo.name === props.repoName);
// Clean query string if repo is not available
if (isUndefined(activeRepo)) {
dispatch(unselectOrg());
history.replace({
search: `?page=${activePage}`,
});
}
} else {
updatePageNumber();
}
}
setApiError(null);
setIsLoading(false);
} catch (err: any) {
setIsLoading(false);
if (err.kind !== ErrorKind.Unauthorized) {
setApiError('An error occurred getting the repositories, please try again later.');
setRepositories([]);
} else {
props.onAuthError();
}
}
}
useEffect(() => {
if (isUndefined(props.activePage)) {
updatePageNumber();
}
}, []); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (props.activePage && activePage !== parseInt(props.activePage)) {
fetchRepositories();
}
}, [activePage]); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (!isUndefined(repositories)) {
if (activePage === 1) {
// fetchRepositories is forced when context changes
fetchRepositories();
} else {
// when current page is different to 1, to update page number fetchRepositories is called
onPageNumberChange(1);
}
}
}, [activeOrg]); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (activeOrg !== ctx.prefs.controlPanel.selectedOrg) {
setActiveOrg(ctx.prefs.controlPanel.selectedOrg);
}
}, [ctx.prefs.controlPanel.selectedOrg]); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
fetchRepositories();
}, []); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<main
role="main"
className="pe-xs-0 pe-sm-3 pe-md-0 d-flex flex-column flex-md-row justify-content-between my-md-4"
>
<div className="flex-grow-1 w-100">
<div>
<div className="d-flex flex-row align-items-center justify-content-between pb-2 border-bottom">
<div className={`h3 pb-0 ${styles.title}`}>Repositories</div>
<div>
<button
className={`btn btn-outline-secondary btn-sm text-uppercase me-0 me-md-2 ${styles.btnAction}`}
onClick={fetchRepositories}
aria-label="Refresh repositories list"
>
<div className="d-flex flex-row align-items-center justify-content-center">
<IoMdRefresh className="d-inline d-md-none" />
<IoMdRefreshCircle className="d-none d-md-inline me-2" />
<span className="d-none d-md-inline">Refresh</span>
</div>
</button>
<button
className={`btn btn-outline-secondary btn-sm text-uppercase me-0 me-md-2 ${styles.btnAction}`}
onClick={() => setOpenClaimRepo(true)}
aria-label="Open claim repository modal"
>
<div className="d-flex flex-row align-items-center justify-content-center">
<RiArrowLeftRightLine className="me-md-2" />
<span className="d-none d-md-inline">Claim ownership</span>
</div>
</button>
<ActionBtn
className={`btn btn-outline-secondary btn-sm text-uppercase ${styles.btnAction}`}
contentClassName="justify-content-center"
onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setModalStatus({ open: true });
}}
action={AuthorizerAction.AddOrganizationRepository}
label="Open add repository modal"
>
<>
<MdAdd className="d-inline d-md-none" />
<MdAddCircle className="d-none d-md-inline me-2" />
<span className="d-none d-md-inline">Add</span>
</>
</ActionBtn>
</div>
</div>
</div>
{modalStatus.open && (
<RepositoryModal
open={modalStatus.open}
repository={modalStatus.repository}
onSuccess={fetchRepositories}
onAuthError={props.onAuthError}
onClose={() => setModalStatus({ open: false })}
/>
)}
{openClaimRepo && (
<ClaimOwnershipRepositoryModal
open={openClaimRepo}
onAuthError={props.onAuthError}
onClose={() => setOpenClaimRepo(false)}
onSuccess={fetchRepositories}
/>
)}
{(isLoading || isUndefined(repositories)) && <Loading />}
<p className="mt-5">
If you want your repositories to be labeled as <span className="fw-bold">Verified Publisher</span>, you can
add a{' '}
<ExternalLink
href="https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml"
className="text-reset"
label="Open documentation"
>
<u>metadata file</u>
</ExternalLink>{' '}
to each of them including the repository ID provided below. This label will let users know that you own or
have control over the repository. The repository metadata file must be located at the path used in the
repository URL.
</p>
{!isUndefined(repositories) && (
<>
{repositories.length === 0 ? (
<NoData issuesLinkVisible={!isNull(apiError)}>
{isNull(apiError) ? (
<>
<p className="h6 my-4">Add your first repository!</p>
<ActionBtn
className="btn btn-sm btn-outline-secondary"
onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setModalStatus({ open: true });
}}
action={AuthorizerAction.AddOrganizationRepository}
label="Open add first repository modal"
>
<div className="d-flex flex-row align-items-center text-uppercase">
<MdAddCircle className="me-2" />
<span>Add repository</span>
</div>
</ActionBtn>
</>
) : (
<>{apiError}</>
)}
</NoData>
) : (
<>
<div className="row mt-3 mt-md-4 gx-0 gx-xxl-4" data-testid="repoList">
{repositories.map((repo: Repo) => (
<RepositoryCard
key={`repo_${repo.name}`}
repository={repo}
visibleModal={
// Legacy - old tracking errors email were not passing modal param
!isUndefined(props.repoName) && repo.name === props.repoName
? props.visibleModal || 'tracking'
: undefined
}
setModalStatus={setModalStatus}
onSuccess={fetchRepositories}
onAuthError={props.onAuthError}
/>
))}
</div>
{!isUndefined(total) && (
<Pagination
limit={DEFAULT_LIMIT}
offset={offset}
total={total}
active={activePage}
className="my-5"
onChange={onPageNumberChange}
/>
)}
</>
)}
</>
)}
</div>
</main>
);
}
Example #7
Source File: Modal.tsx From hub with Apache License 2.0 | 4 votes |
APIKeyModal = (props: Props) => {
const form = useRef<HTMLFormElement>(null);
const nameInput = useRef<RefInputField>(null);
const [isSending, setIsSending] = useState(false);
const [isValidated, setIsValidated] = useState(false);
const [apiKey, setApiKey] = useState<APIKey | undefined>(props.apiKey);
const [apiError, setApiError] = useState<string | null>(null);
const [apiKeyCode, setApiKeyCode] = useState<APIKeyCode | undefined>(undefined);
// Clean API error when form is focused after validation
const cleanApiError = () => {
if (!isNull(apiError)) {
setApiError(null);
}
};
const onCloseModal = () => {
setApiKey(undefined);
setApiKeyCode(undefined);
setIsValidated(false);
setApiError(null);
props.onClose();
};
async function handleAPIKey(name: string) {
try {
if (props.apiKey) {
await API.updateAPIKey(props.apiKey.apiKeyId!, name);
} else {
setApiKeyCode(await API.addAPIKey(name));
}
if (props.onSuccess) {
props.onSuccess();
}
setIsSending(false);
// Modal is closed only when updating API key
if (props.apiKey) {
onCloseModal();
}
} catch (err: any) {
setIsSending(false);
if (err.kind !== ErrorKind.Unauthorized) {
setApiError(
`An error occurred ${isUndefined(props.apiKey) ? 'adding' : 'updating'} the API key, please try again later.`
);
} else {
props.onAuthError();
}
}
}
const submitForm = () => {
cleanApiError();
setIsSending(true);
if (form.current) {
const { isValid, apiKey } = validateForm(form.current);
if (isValid && apiKey) {
handleAPIKey(apiKey.name);
} else {
setIsSending(false);
}
}
};
const validateForm = (form: HTMLFormElement): FormValidation => {
let apiKey: APIKey | null = null;
const formData = new FormData(form);
const isValid = form.checkValidity();
if (isValid) {
apiKey = {
name: formData.get('name') as string,
};
}
setIsValidated(true);
return { isValid, apiKey };
};
const handleOnReturnKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter' && form) {
event.preventDefault();
event.stopPropagation();
submitForm();
}
};
useEffect(() => {
async function getAPIKey() {
try {
const currentAPIKey = await API.getAPIKey(props.apiKey!.apiKeyId!);
setApiKey(currentAPIKey);
nameInput.current!.updateValue(currentAPIKey.name);
} catch (err: any) {
if (err.kind === ErrorKind.Unauthorized) {
props.onAuthError();
}
}
}
if (props.apiKey && isUndefined(apiKey)) {
getAPIKey();
}
}, [apiKey, props, props.apiKey]);
const sendBtn = (
<button
className="btn btn-sm btn-outline-secondary"
type="button"
disabled={isSending}
onClick={submitForm}
aria-label={`${props.apiKey ? 'Update' : 'Add'} API key`}
>
{isSending ? (
<>
<span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
<span className="ms-2">{`${props.apiKey ? 'Updating' : 'Adding'} API key`}</span>
</>
) : (
<div className="d-flex flex-row align-items-center text-uppercase">
{isUndefined(props.apiKey) ? (
<>
<MdAddCircle className="me-2" />
<div>Add</div>
</>
) : (
<>
<FaPencilAlt className="me-2" />
<div>Update</div>
</>
)}
</div>
)}
</button>
);
return (
<Modal
header={
<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>{`${props.apiKey ? 'Update' : 'Add'} API key`}</div>
}
open={props.open}
modalClassName={styles.modal}
closeButton={isUndefined(apiKeyCode) ? sendBtn : undefined}
onClose={onCloseModal}
error={apiError}
cleanError={cleanApiError}
>
<div className={`w-100 ${styles.contentWrapper}`}>
{apiKeyCode ? (
<>
<div className="d-flex justify-content-between mb-2">
<SmallTitle text="API-KEY-ID" />
<div>
<ButtonCopyToClipboard text={apiKeyCode.apiKeyId} label="Copy API key ID to clipboard" />
</div>
</div>
<SyntaxHighlighter
language="bash"
style={docco}
customStyle={{
backgroundColor: 'var(--color-1-10)',
}}
>
{apiKeyCode.apiKeyId}
</SyntaxHighlighter>
<div className="d-flex justify-content-between mb-2">
<SmallTitle text="API-KEY-SECRET" />
<div>
<ButtonCopyToClipboard text={apiKeyCode.secret} label="Copy API key secret to clipboard" />
</div>
</div>
<SyntaxHighlighter
language="bash"
style={docco}
customStyle={{
backgroundColor: 'var(--color-1-10)',
}}
>
{apiKeyCode.secret}
</SyntaxHighlighter>
<small className="text-muted">
These are the credentials you will need to provide when making requests to the API. Please, copy and store
them in a safe place.{' '}
<b>
<u>You will not be able to see the secret again when you close this window.</u>
</b>{' '}
For more information please see the authorize section in the{' '}
<ExternalLink className="text-primary fw-bold" href="/docs/api" label="Open documentation">
API docs
</ExternalLink>
.
</small>
<div className={`alert alert-warning mt-4 mb-2 ${styles.alert}`}>
<span className="fw-bold text-uppercase">Important:</span> the API key you've just generated can be used
to perform <u className="fw-bold">ANY</u> operation you can, so please store it safely and don't share it
with others.
</div>
</>
) : (
<form
data-testid="apiKeyForm"
ref={form}
className={classnames('w-100 mt-3', { 'needs-validation': !isValidated }, { 'was-validated': isValidated })}
onFocus={cleanApiError}
autoComplete="on"
noValidate
>
<InputField
ref={nameInput}
type="text"
label="Name"
labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
name="name"
value={isUndefined(apiKey) ? '' : apiKey.name}
invalidText={{
default: 'This field is required',
}}
autoComplete="off"
onKeyDown={handleOnReturnKeyDown}
required
/>
</form>
)}
</div>
</Modal>
);
}
Example #8
Source File: index.tsx From hub with Apache License 2.0 | 4 votes |
APIKeysSection = (props: Props) => {
const history = useHistory();
const [isLoading, setIsLoading] = useState(false);
const [apiKeysList, setApiKeysList] = useState<APIKey[] | undefined>(undefined);
const [apiError, setApiError] = useState<string | JSX.Element | null>(null);
const [modalStatus, setModalStatus] = useState<ModalStatus>({
open: false,
});
const [activePage, setActivePage] = useState<number>(props.activePage ? parseInt(props.activePage) : 1);
const calculateOffset = (pageNumber?: number): number => {
return DEFAULT_LIMIT * ((pageNumber || activePage) - 1);
};
const [offset, setOffset] = useState<number>(calculateOffset());
const [total, setTotal] = useState<number | undefined>(undefined);
const onPageNumberChange = (pageNumber: number): void => {
setOffset(calculateOffset(pageNumber));
setActivePage(pageNumber);
};
const updatePageNumber = () => {
history.replace({
search: `?page=${activePage}`,
});
};
async function getAPIKeys() {
try {
setIsLoading(true);
const data = await API.getAPIKeys({
limit: DEFAULT_LIMIT,
offset: offset,
});
const total = parseInt(data.paginationTotalCount);
if (total > 0 && data.items.length === 0) {
onPageNumberChange(1);
} else {
setApiKeysList(data.items);
setTotal(total);
}
updatePageNumber();
setApiError(null);
setIsLoading(false);
} catch (err: any) {
setIsLoading(false);
if (err.kind !== ErrorKind.Unauthorized) {
setApiError('An error occurred getting your API keys, please try again later.');
setApiKeysList([]);
} else {
props.onAuthError();
}
}
}
useEffect(() => {
getAPIKeys();
}, []); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (props.activePage && activePage !== parseInt(props.activePage)) {
getAPIKeys();
}
}, [activePage]); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<div className="d-flex flex-column flex-grow-1">
{(isUndefined(apiKeysList) || isLoading) && <Loading />}
<main role="main" className="p-0">
<div className="flex-grow-1">
<div className="d-flex flex-row align-items-center justify-content-between pb-2 border-bottom">
<div className={`h3 pb-0 ${styles.title}`}>API keys</div>
<div>
<button
className={`btn btn-outline-secondary btn-sm text-uppercase ${styles.btnAction}`}
onClick={() => setModalStatus({ open: true })}
aria-label="Open modal to add API key"
>
<div className="d-flex flex-row align-items-center justify-content-center">
<MdAdd className="d-inline d-md-none" />
<MdAddCircle className="d-none d-md-inline me-2" />
<span className="d-none d-md-inline">Add</span>
</div>
</button>
</div>
</div>
<div className="mt-4">
{!isUndefined(apiKeysList) && (
<div className="mt-4 mt-md-5">
{apiKeysList.length === 0 ? (
<NoData issuesLinkVisible={!isNull(apiError)}>
{isNull(apiError) ? (
<>
<p className="h6 my-4">Add your first API key!</p>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setModalStatus({ open: true })}
aria-label="Open API key modal to add the first one"
>
<div className="d-flex flex-row align-items-center text-uppercase">
<MdAddCircle className="me-2" />
<span>Add API key</span>
</div>
</button>
</>
) : (
<>{apiError}</>
)}
</NoData>
) : (
<>
<div className="row mt-4 mt-md-5 gx-0 gx-xxl-4" data-testid="apiKeysList">
{apiKeysList.map((apiKey: APIKey) => (
<APIKeyCard
key={apiKey.apiKeyId!}
apiKey={apiKey}
setModalStatus={setModalStatus}
onSuccess={getAPIKeys}
onAuthError={props.onAuthError}
/>
))}
</div>
{!isUndefined(total) && (
<div className="mx-auto">
<Pagination
limit={DEFAULT_LIMIT}
offset={offset}
total={total}
active={activePage}
className="my-5"
onChange={onPageNumberChange}
/>
</div>
)}
</>
)}
</div>
)}
</div>
<APIKeyModal
{...modalStatus}
onSuccess={getAPIKeys}
onClose={() => setModalStatus({ open: false })}
onAuthError={props.onAuthError}
/>
</div>
</main>
</div>
);
}
Example #9
Source File: Modal.tsx From hub with Apache License 2.0 | 4 votes |
SubscriptionModal = (props: Props) => {
const searchWrapperRef = useRef<HTMLDivElement | null>(null);
const [apiError, setApiError] = useState(null);
const [eventKinds, setEventKinds] = useState<EventKind[]>([EventKind.NewPackageRelease]);
const [packageItem, setPackageItem] = useState<Package | null>(null);
const [isSending, setIsSending] = useState<boolean>(false);
const onCloseModal = () => {
setPackageItem(null);
setEventKinds([EventKind.NewPackageRelease]);
props.onClose();
};
const submitForm = () => {
if (!isNull(packageItem)) {
eventKinds.forEach((event: EventKind, index: number) => {
addSubscription(event, index === eventKinds.length - 1);
});
}
};
const onPackageSelection = (packageItem: Package): void => {
setPackageItem(packageItem);
};
const getSubscribedPackagesIds = (): string[] => {
if (isUndefined(props.subscriptions)) return [];
const selectedPackages = props.subscriptions.filter(
(item: Package) =>
!isUndefined(item.eventKinds) && eventKinds.every((el: EventKind) => item.eventKinds!.includes(el))
);
return selectedPackages.map((item: Package) => item.packageId);
};
const updateEventKindList = (eventKind: EventKind) => {
let updatedEventKinds: EventKind[] = [...eventKinds];
if (eventKinds.includes(eventKind)) {
// At least event kind must be selected
if (updatedEventKinds.length > 1) {
updatedEventKinds = eventKinds.filter((kind: EventKind) => kind !== eventKind);
}
} else {
updatedEventKinds.push(eventKind);
}
setEventKinds(updatedEventKinds);
};
async function addSubscription(event: EventKind, onLastEvent?: boolean) {
try {
setIsSending(true);
await API.addSubscription(packageItem!.packageId, event);
setPackageItem(null);
setIsSending(false);
if (onLastEvent) {
props.onSuccess();
props.onClose();
}
} catch (err: any) {
setIsSending(false);
if (err.kind !== ErrorKind.Unauthorized) {
alertDispatcher.postAlert({
type: 'danger',
message: `An error occurred subscribing to ${props.getNotificationTitle(event)} notification for ${
packageItem!.displayName || packageItem!.name
} package, please try again later.`,
});
}
}
}
const getPublisher = (pkg: Package): JSX.Element => {
return (
<>
{pkg.repository.userAlias || pkg.repository.organizationDisplayName || pkg.repository.organizationName}
<small className="ms-2">
(<small className={`d-none d-md-inline text-uppercase text-muted ${styles.legend}`}>Repo: </small>
{pkg.repository.displayName || pkg.repository.name})
</small>
</>
);
};
return (
<Modal
header={<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>Add subscription</div>}
open={props.open}
modalDialogClassName={styles.modal}
closeButton={
<button
className="btn btn-sm btn-outline-secondary"
type="button"
disabled={isNull(packageItem) || isSending}
onClick={submitForm}
aria-label="Add subscription"
>
{isSending ? (
<>
<span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
<span className="ms-2">Adding subscription</span>
</>
) : (
<div className="d-flex flex-row align-items-center text-uppercase">
<MdAddCircle className="me-2" />
<div>Add</div>
</div>
)}
</button>
}
onClose={onCloseModal}
error={apiError}
cleanError={() => setApiError(null)}
excludedRefs={[searchWrapperRef]}
noScrollable
>
<div className="w-100 position-relative">
<label className={`form-label fw-bold ${styles.label}`} htmlFor="kind" id="events-group">
Events
</label>
<div role="group" aria-labelledby="events-group" className="pb-2">
{PACKAGE_SUBSCRIPTIONS_LIST.map((subs: SubscriptionItem) => {
return (
<div className="mb-2" key={`radio_${subs.name}`}>
<CheckBox
key={`check_${subs.kind}`}
name="eventKind"
value={subs.kind.toString()}
icon={subs.icon}
label={subs.title}
checked={eventKinds.includes(subs.kind)}
onChange={() => {
updateEventKindList(subs.kind);
}}
device="desktop"
/>
</div>
);
})}
</div>
<div className="d-flex flex-column mb-3">
<label className={`form-label fw-bold ${styles.label}`} htmlFor="description" id="subscriptions-pkg-list">
Package
</label>
<small className="mb-2">Select the package you'd like to subscribe to:</small>
{!isNull(packageItem) ? (
<div
data-testid="activePackageItem"
className={`border border-secondary w-100 rounded mt-1 ${styles.packageWrapper}`}
>
<div className="d-flex flex-row flex-nowrap align-items-stretch justify-content-between">
<div className="flex-grow-1 text-truncate py-2">
<div className="d-flex flex-row align-items-center h-100 text-truncate">
<div className="d-none d-md-inline">
<RepositoryIcon kind={packageItem.repository.kind} className={`mx-3 w-auto ${styles.icon}`} />
</div>
<div
className={`d-flex align-items-center justify-content-center overflow-hidden p-1 ms-2 ms-md-0 rounded-circle border border-2 bg-white ${styles.imageWrapper} imageWrapper`}
>
<Image
alt={packageItem.displayName || packageItem.name}
imageId={packageItem.logoImageId}
className={`fs-4 ${styles.image}`}
kind={packageItem.repository.kind}
/>
</div>
<div className="ms-2 me-2 me-sm-0 fw-bold mb-0 text-truncate">
{packageItem.displayName || packageItem.name}
<span className={`d-inline d-sm-none ${styles.legend}`}>
<span className="mx-2">/</span>
{getPublisher(packageItem)}
</span>
</div>
<div className="px-2 ms-auto w-50 text-dark text-truncate d-none d-sm-inline">
{getPublisher(packageItem)}
</div>
</div>
</div>
<div>
<button
className={`btn btn-close h-100 rounded-0 border-start px-3 py-0 ${styles.closeButton}`}
onClick={() => setPackageItem(null)}
aria-label="Close"
></button>
</div>
</div>
</div>
) : (
<div className={`mt-2 ${styles.searchWrapper}`} ref={searchWrapperRef}>
<SearchPackages
disabledPackages={getSubscribedPackagesIds()}
onSelection={onPackageSelection}
label="subscriptions-pkg-list"
/>
</div>
)}
</div>
</div>
</Modal>
);
}
Example #10
Source File: index.tsx From hub with Apache License 2.0 | 4 votes |
PackagesSection = (props: Props) => {
const title = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [packages, setPackages] = useState<Package[] | undefined>(undefined);
const [modalStatus, setModalStatus] = useState<boolean>(false);
const [activePage, setActivePage] = useState<number>(1);
const calculateOffset = (pageNumber?: number): number => {
return DEFAULT_LIMIT * ((pageNumber || activePage) - 1);
};
const [offset, setOffset] = useState<number>(calculateOffset());
const [total, setTotal] = useState<number | undefined>(undefined);
const onPageNumberChange = (pageNumber: number): void => {
setOffset(calculateOffset(pageNumber));
setActivePage(pageNumber);
if (title && title.current) {
title.current.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth' });
}
};
const getNotificationTitle = (kind: EventKind): string => {
let title = '';
const notif = PACKAGE_SUBSCRIPTIONS_LIST.find((subs: SubscriptionItem) => subs.kind === kind);
if (notif) {
title = notif.title.toLowerCase();
}
return title;
};
const updateSubscriptionsPackagesOptimistically = (kind: EventKind, isActive: boolean, packageId: string) => {
const packageToUpdate = packages ? packages.find((item: Package) => item.packageId === packageId) : undefined;
if (packageToUpdate && packageToUpdate.eventKinds) {
const newPackages = packages!.filter((item: Package) => item.packageId !== packageId);
if (isActive) {
packageToUpdate.eventKinds = packageToUpdate.eventKinds.filter((notifKind: number) => notifKind !== kind);
} else {
packageToUpdate.eventKinds.push(kind);
}
if (packageToUpdate.eventKinds.length > 0) {
newPackages.push(packageToUpdate);
}
setPackages(newPackages);
}
};
async function getSubscriptions() {
try {
setIsLoading(true);
const data = await API.getUserSubscriptions({
limit: DEFAULT_LIMIT,
offset: offset,
});
const total = parseInt(data.paginationTotalCount);
if (total > 0 && data.items.length === 0) {
onPageNumberChange(1);
} else {
setPackages(data.items);
setTotal(total);
}
setIsLoading(false);
} catch (err: any) {
setIsLoading(false);
if (err.kind !== ErrorKind.Unauthorized) {
alertDispatcher.postAlert({
type: 'danger',
message: 'An error occurred getting your subscriptions, please try again later.',
});
setPackages([]);
} else {
props.onAuthError();
}
}
}
async function changeSubscription(packageId: string, kind: EventKind, isActive: boolean, packageName: string) {
updateSubscriptionsPackagesOptimistically(kind, isActive, packageId);
try {
if (isActive) {
await API.deleteSubscription(packageId, kind);
} else {
await API.addSubscription(packageId, kind);
}
getSubscriptions();
} catch (err: any) {
if (err.kind !== ErrorKind.Unauthorized) {
alertDispatcher.postAlert({
type: 'danger',
message: `An error occurred ${isActive ? 'unsubscribing from' : 'subscribing to'} ${getNotificationTitle(
kind
)} notification for ${packageName} package, please try again later.`,
});
getSubscriptions(); // Get subscriptions if changeSubscription fails
} else {
props.onAuthError();
}
}
}
useEffect(() => {
getSubscriptions();
}, [activePage]); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<>
{(isUndefined(packages) || isLoading) && <Loading />}
<div className="d-flex flex-row align-items-start justify-content-between pb-2 mt-5">
<div ref={title} className={`h4 mb-0 ${styles.title}`}>
Packages
</div>
<div>
<button
className={`btn btn-outline-secondary btn-sm text-uppercase ${styles.btnAction}`}
onClick={() => setModalStatus(true)}
aria-label="Open subscription modal"
>
<div className="d-flex flex-row align-items-center justify-content-center">
<MdAdd className="d-inline d-md-none" />
<MdAddCircle className="d-none d-md-inline me-2" />
<span className="d-none d-md-inline">Add</span>
</div>
</button>
</div>
</div>
<div className="mx-auto mt-3 mt-md-3">
<p className="m-0">
You will receive an email notification when an event that matches any of the subscriptions in the list is
fired.
</p>
<div className="mt-4 mt-md-5">
{!isUndefined(packages) && packages.length > 0 && (
<>
<div className="d-none d-sm-inline" data-testid="packagesList">
<div className="row">
<div className="col-12 col-xxxl-10">
<table className={`table table-bordered table-hover ${styles.table}`}>
<thead>
<tr className={styles.tableTitle}>
<th
scope="col"
className={`align-middle text-center d-none d-sm-table-cell ${styles.fitCell}`}
>
Kind
</th>
<th scope="col" className="align-middle w-50">
Package
</th>
<th scope="col" className="align-middle w-50">
Publisher
</th>
{PACKAGE_SUBSCRIPTIONS_LIST.map((subs: SubscriptionItem) => (
<th
scope="col"
className={`align-middle text-nowrap ${styles.fitCell}`}
key={`title_${subs.kind}`}
>
<div className="d-flex flex-row align-items-center justify-content-center">
{subs.icon}
<span className="d-none d-lg-inline ms-2">{subs.title}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody className={styles.body}>
{packages.map((item: Package) => (
<tr key={`subs_${item.packageId}`} data-testid="subsTableCell">
<td className="align-middle text-center d-none d-sm-table-cell">
<RepositoryIcon kind={item.repository.kind} className={`h-auto ${styles.icon}`} />
</td>
<td className="align-middle">
<div className="d-flex flex-row align-items-center">
<div
className={`d-flex align-items-center justify-content-center overflow-hidden p-1 rounded-circle border bg-white ${styles.imageWrapper} imageWrapper`}
>
<Image
alt={item.displayName || item.name}
imageId={item.logoImageId}
className={`fs-4 ${styles.image}`}
kind={item.repository.kind}
/>
</div>
<Link
data-testid="packageLink"
className="ms-2 text-dark"
to={{
pathname: buildPackageURL(item.normalizedName, item.repository, item.version!),
}}
aria-label={`Open ${item.displayName || item.name} package`}
>
{item.displayName || item.name}
</Link>
</div>
</td>
<td className="align-middle position-relative">
{item.repository.userAlias ? (
<Link
data-testid="userLink"
className="text-dark"
to={{
pathname: '/packages/search',
search: prepareQueryString({
pageNumber: 1,
filters: {
user: [item.repository.userAlias!],
},
}),
}}
aria-label={`Filter by ${item.repository.userAlias} user`}
>
{item.repository.userAlias}
</Link>
) : (
<Link
data-testid="orgLink"
className="text-dark"
to={{
pathname: '/packages/search',
search: prepareQueryString({
pageNumber: 1,
filters: {
org: [item.repository.organizationName!],
},
}),
}}
aria-label={`Filter by ${
item.repository.organizationDisplayName || item.repository.organizationName
} organization`}
>
{item.repository.organizationDisplayName || item.repository.organizationName}
</Link>
)}
<small className="ms-2">
(<span className={`text-uppercase text-muted ${styles.legend}`}>Repo: </span>
<Link
data-testid="repoLink"
className="text-dark"
to={{
pathname: '/packages/search',
search: prepareQueryString({
pageNumber: 1,
filters: {
repo: [item.repository.name],
},
}),
}}
aria-label={`Filter by ${
item.repository.displayName || item.repository.name
} repository`}
>
{item.repository.displayName || item.repository.name}
</Link>
)
</small>
</td>
{PACKAGE_SUBSCRIPTIONS_LIST.map((subs: SubscriptionItem) => {
const isActive = !isUndefined(item.eventKinds) && item.eventKinds.includes(subs.kind);
const id = `subs_${item.packageId}_${subs.kind}`;
return (
<td className="align-middle text-center" key={`td_${item.normalizedName}_${subs.kind}`}>
<div className="text-center">
<div className="form-switch">
<input
data-testid={`${item.name}_${subs.name}_input`}
id={id}
type="checkbox"
role="switch"
className={`form-check-input ${styles.checkbox}`}
disabled={!subs.enabled}
onChange={() =>
changeSubscription(
item.packageId,
subs.kind,
isActive,
item.displayName || item.name
)
}
checked={isActive}
/>
</div>
</div>
</td>
);
})}
</tr>
))}
{!isUndefined(total) && total > DEFAULT_LIMIT && (
<tr className={styles.paginationCell}>
<td className="align-middle text-center" colSpan={5}>
<Pagination
limit={DEFAULT_LIMIT}
offset={offset}
total={total}
active={activePage}
className="my-3"
onChange={onPageNumberChange}
/>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="d-inline d-sm-none">
{packages.map((item: Package) => (
<PackageCard key={item.packageId} package={item} changeSubscription={changeSubscription} />
))}
{!isUndefined(total) && (
<div className="mx-auto">
<Pagination
limit={DEFAULT_LIMIT}
offset={offset}
total={total}
active={activePage}
className="my-5"
onChange={onPageNumberChange}
/>
</div>
)}
</div>
</>
)}
</div>
</div>
<SubscriptionModal
open={modalStatus}
subscriptions={packages}
onSuccess={getSubscriptions}
onClose={() => setModalStatus(false)}
getNotificationTitle={getNotificationTitle}
/>
</>
);
}
Example #11
Source File: Form.tsx From hub with Apache License 2.0 | 4 votes |
WebhookForm = (props: Props) => {
const { ctx } = useContext(AppCtx);
const form = useRef<HTMLFormElement>(null);
const urlInput = useRef<RefInputField>(null);
const contentTypeInput = useRef<RefInputField>(null);
const [isSending, setIsSending] = useState(false);
const [isValidated, setIsValidated] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const [selectedPackages, setSelectedPackages] = useState<Package[]>(
!isUndefined(props.webhook) && props.webhook.packages ? props.webhook.packages : []
);
const [eventKinds, setEventKinds] = useState<EventKind[]>(
!isUndefined(props.webhook) ? props.webhook.eventKinds : [EventKind.NewPackageRelease]
);
const [isActive, setIsActive] = useState<boolean>(!isUndefined(props.webhook) ? props.webhook.active : true);
const [contentType, setContentType] = useState<string>(
!isUndefined(props.webhook) && props.webhook.contentType ? props.webhook.contentType : ''
);
const [template, setTemplate] = useState<string>(
!isUndefined(props.webhook) && props.webhook.template ? props.webhook.template : ''
);
const [isAvailableTest, setIsAvailableTest] = useState<boolean>(false);
const [currentTestWebhook, setCurrentTestWebhook] = useState<TestWebhook | null>(null);
const [isTestSent, setIsTestSent] = useState<boolean>(false);
const [isSendingTest, setIsSendingTest] = useState<boolean>(false);
const getPayloadKind = (): PayloadKind => {
let currentPayloadKind: PayloadKind = DEFAULT_PAYLOAD_KIND;
if (!isUndefined(props.webhook) && props.webhook.contentType && props.webhook.template) {
currentPayloadKind = PayloadKind.custom;
}
return currentPayloadKind;
};
const [payloadKind, setPayloadKind] = useState<PayloadKind>(getPayloadKind());
const onCloseForm = () => {
props.onClose();
};
const onContentTypeChange = (e: ChangeEvent<HTMLInputElement>) => {
setContentType(e.target.value);
};
async function handleWebhook(webhook: Webhook) {
try {
setIsSending(true);
if (isUndefined(props.webhook)) {
await API.addWebhook(webhook, ctx.prefs.controlPanel.selectedOrg!);
} else {
await API.updateWebhook(webhook, ctx.prefs.controlPanel.selectedOrg!);
}
setIsSending(false);
props.onSuccess();
onCloseForm();
} catch (err: any) {
setIsSending(false);
if (err.kind !== ErrorKind.Unauthorized) {
let error = compoundErrorMessage(
err,
`An error occurred ${isUndefined(props.webhook) ? 'adding' : 'updating'} the webhook`
);
if (!isUndefined(props.webhook) && err.kind === ErrorKind.Forbidden) {
error = `You do not have permissions to ${isUndefined(props.webhook) ? 'add' : 'update'} the webhook ${
isUndefined(props.webhook) ? 'to' : 'from'
} the organization.`;
}
setApiError(error);
} else {
props.onAuthError();
}
}
}
async function triggerWebhookTest(webhook: TestWebhook) {
try {
setIsSendingTest(true);
setIsTestSent(false);
await API.triggerWebhookTest(webhook);
setIsTestSent(true);
setIsSendingTest(false);
} catch (err: any) {
setIsSendingTest(false);
if (err.kind !== ErrorKind.Unauthorized) {
let error = compoundErrorMessage(err, `An error occurred testing the webhook`);
setApiError(error);
} else {
props.onAuthError();
}
}
}
const triggerTest = () => {
if (!isNull(currentTestWebhook)) {
cleanApiError();
triggerWebhookTest(currentTestWebhook);
}
};
const submitForm = () => {
if (form.current) {
cleanApiError();
const { isValid, webhook } = validateForm(form.current);
if (isValid && !isNull(webhook)) {
handleWebhook(webhook);
}
}
};
const validateForm = (form: HTMLFormElement): FormValidation => {
let webhook: Webhook | null = null;
const formData = new FormData(form);
const isValid = form.checkValidity() && selectedPackages.length > 0;
if (isValid) {
webhook = {
name: formData.get('name') as string,
url: formData.get('url') as string,
secret: formData.get('secret') as string,
description: formData.get('description') as string,
eventKinds: eventKinds,
active: isActive,
packages: selectedPackages,
};
if (payloadKind === PayloadKind.custom) {
webhook = {
...webhook,
template: template,
contentType: contentType,
};
}
if (props.webhook) {
webhook = {
...webhook,
webhookId: props.webhook.webhookId,
};
}
}
setIsValidated(true);
return { isValid, webhook };
};
const addPackage = (packageItem: Package) => {
const packagesList = [...selectedPackages];
packagesList.push(packageItem);
setSelectedPackages(packagesList);
};
const deletePackage = (packageId: string) => {
const packagesList = selectedPackages.filter((item: Package) => item.packageId !== packageId);
setSelectedPackages(packagesList);
};
const getPackagesIds = (): string[] => {
return selectedPackages.map((item: Package) => item.packageId);
};
const updateEventKindList = (eventKind: EventKind) => {
let updatedEventKinds: EventKind[] = [...eventKinds];
if (eventKinds.includes(eventKind)) {
// At least event kind must be selected
if (updatedEventKinds.length > 1) {
updatedEventKinds = eventKinds.filter((kind: EventKind) => kind !== eventKind);
}
} else {
updatedEventKinds.push(eventKind);
}
setEventKinds(updatedEventKinds);
};
const cleanApiError = () => {
if (!isNull(apiError)) {
setApiError(null);
}
};
const updateTemplate = (e: ChangeEvent<HTMLTextAreaElement>) => {
setTemplate(e.target.value);
checkTestAvailability();
};
const checkTestAvailability = () => {
const formData = new FormData(form.current!);
let webhook: TestWebhook = {
url: formData.get('url') as string,
eventKinds: eventKinds,
};
if (payloadKind === PayloadKind.custom) {
webhook = {
...webhook,
template: template,
contentType: contentType,
};
}
const isFilled = Object.values(webhook).every((x) => x !== null && x !== '');
if (urlInput.current!.checkValidity() && isFilled) {
setCurrentTestWebhook(webhook);
setIsAvailableTest(true);
} else {
setCurrentTestWebhook(null);
setIsAvailableTest(false);
}
};
useEffect(() => {
checkTestAvailability();
}, []); /* eslint-disable-line react-hooks/exhaustive-deps */
const getPublisher = (pkg: Package): JSX.Element => {
return (
<>
{pkg.repository.userAlias || pkg.repository.organizationDisplayName || pkg.repository.organizationName}
<small className="ms-2">
(<span className={`text-uppercase text-muted d-none d-sm-inline ${styles.legend}`}>Repo: </span>
<span className="text-dark">{pkg.repository.displayName || pkg.repository.name}</span>)
</small>
</>
);
};
return (
<div>
<div className="mb-4 pb-2 border-bottom">
<button
className={`btn btn-link text-dark btn-sm ps-0 d-flex align-items-center ${styles.link}`}
onClick={onCloseForm}
aria-label="Back to webhooks list"
>
<IoIosArrowBack className="me-2" />
Back to webhooks list
</button>
</div>
<div className="mt-2">
<form
ref={form}
data-testid="webhookForm"
className={classnames('w-100', { 'needs-validation': !isValidated }, { 'was-validated': isValidated })}
onClick={() => setApiError(null)}
autoComplete="off"
noValidate
>
<div className="d-flex">
<div className="col-md-8">
<InputField
type="text"
label="Name"
labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
name="name"
value={!isUndefined(props.webhook) ? props.webhook.name : ''}
invalidText={{
default: 'This field is required',
}}
validateOnBlur
required
/>
</div>
</div>
<div className="d-flex">
<div className="col-md-8">
<InputField
type="text"
label="Description"
name="description"
value={!isUndefined(props.webhook) ? props.webhook.description : ''}
/>
</div>
</div>
<div>
<label className={`form-label fw-bold ${styles.label}`} htmlFor="url">
Url<small className="ms-1 fst-italic">(Required)</small>
</label>
<div className="form-text text-muted mb-2 mt-0">
A POST request will be sent to the provided URL when any of the events selected in the triggers section
happens.
</div>
<div className="d-flex">
<div className="col-md-8">
<InputField
ref={urlInput}
type="url"
name="url"
value={!isUndefined(props.webhook) ? props.webhook.url : ''}
invalidText={{
default: 'This field is required',
typeMismatch: 'Please enter a valid url',
}}
onChange={checkTestAvailability}
validateOnBlur
required
/>
</div>
</div>
</div>
<div>
<label className={`form-label fw-bold ${styles.label}`} htmlFor="secret">
Secret
</label>
<div className="form-text text-muted mb-2 mt-0">
If you provide a secret, we'll send it to you in the <span className="fw-bold">X-ArtifactHub-Secret</span>{' '}
header on each request. This will allow you to validate that the request comes from ArtifactHub.
</div>
<div className="d-flex">
<div className="col-md-8">
<InputField type="text" name="secret" value={!isUndefined(props.webhook) ? props.webhook.secret : ''} />
</div>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch ps-0">
<label htmlFor="active" className={`form-check-label fw-bold ${styles.label}`}>
Active
</label>
<input
id="active"
type="checkbox"
role="switch"
className={`position-absolute ms-2 form-check-input ${styles.checkbox}`}
value="true"
onChange={() => setIsActive(!isActive)}
checked={isActive}
/>
</div>
<div className="form-text text-muted mt-2">
This flag indicates if the webhook is active or not. Inactive webhooks will not receive notifications.
</div>
</div>
<div className="h4 pb-2 mt-4 mt-md-5 mb-4 border-bottom">Triggers</div>
<div className="my-4">
<label className={`form-label fw-bold ${styles.label}`} htmlFor="kind" id="events-group">
Events
</label>
<div role="group" aria-labelledby="events-group">
{PACKAGE_SUBSCRIPTIONS_LIST.map((subs: SubscriptionItem) => {
return (
<CheckBox
key={`check_${subs.kind}`}
name="eventKind"
value={subs.kind.toString()}
device="all"
label={subs.title}
checked={eventKinds.includes(subs.kind)}
onChange={() => {
updateEventKindList(subs.kind);
checkTestAvailability();
}}
/>
);
})}
</div>
</div>
<div className="mb-4">
<label className={`form-label fw-bold ${styles.label}`} htmlFor="packages" id="webhook-pkg-list">
Packages<small className="ms-1 fst-italic">(Required)</small>
</label>
<div className="form-text text-muted mb-4 mt-0">
When the events selected happen for any of the packages you've chosen, a notification will be triggered
and the configured url will be called. At least one package must be selected.
</div>
<div className="mb-3 row">
<div className="col-12 col-xxl-10 col-xxxl-8">
<SearchPackages disabledPackages={getPackagesIds()} onSelection={addPackage} label="webhook-pkg-list" />
</div>
</div>
{isValidated && selectedPackages.length === 0 && (
<div className="invalid-feedback mt-0 d-block">At least one package has to be selected</div>
)}
{selectedPackages.length > 0 && (
<div className="row">
<div className="col-12 col-xxl-10 col-xxxl-8">
<table className={`table table-hover table-sm border transparentBorder text-break ${styles.table}`}>
<thead>
<tr className={styles.tableTitle}>
<th scope="col" className={`align-middle d-none d-sm-table-cell ${styles.fitCell}`}></th>
<th scope="col" className={`align-middle ${styles.packageCell}`}>
Package
</th>
<th scope="col" className="align-middle w-50 d-none d-sm-table-cell">
Publisher
</th>
<th scope="col" className={`align-middle ${styles.fitCell}`}></th>
</tr>
</thead>
<tbody className={styles.body}>
{selectedPackages.map((item: Package) => (
<tr key={`subs_${item.packageId}`} data-testid="packageTableCell">
<td className="align-middle text-center d-none d-sm-table-cell">
<RepositoryIcon kind={item.repository.kind} className={`${styles.icon} h-auto mx-2`} />
</td>
<td className="align-middle">
<div className="d-flex flex-row align-items-center">
<div
className={`d-flex align-items-center justify-content-center overflow-hidden p-1 rounded-circle border border-2 bg-white ${styles.imageWrapper} imageWrapper`}
>
<Image
alt={item.displayName || item.name}
imageId={item.logoImageId}
className="mw-100 mh-100 fs-4"
kind={item.repository.kind}
/>
</div>
<div className={`ms-2 text-dark ${styles.cellWrapper}`}>
<div className="text-truncate">
{item.displayName || item.name}
<span className={`d-inline d-sm-none ${styles.legend}`}>
<span className="mx-2">/</span>
{getPublisher(item)}
</span>
</div>
</div>
</div>
</td>
<td className="align-middle position-relative text-dark d-none d-sm-table-cell">
<div className={`d-table w-100 ${styles.cellWrapper}`}>
<div className="text-truncate">{getPublisher(item)}</div>
</div>
</td>
<td className="align-middle">
<button
className={`btn btn-link btn-sm mx-2 ${styles.closeBtn}`}
type="button"
onClick={(event: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
event.stopPropagation();
deletePackage(item.packageId);
}}
aria-label="Delete package from webhook"
>
<MdClose className="text-danger fs-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
<div className="h4 pb-2 mt-4 mt-md-5 mb-4 border-bottom">Payload</div>
<div className="d-flex flex-row mb-3">
{PAYLOAD_KINDS_LIST.map((item: PayloadKindsItem) => {
return (
<div className="form-check me-4" key={`payload_${item.kind}`}>
<input
className="form-check-input"
type="radio"
id={`payload_${item.kind}`}
name="payloadKind"
value={item.name}
checked={payloadKind === item.kind}
onChange={() => {
setPayloadKind(item.kind);
setIsValidated(false);
checkTestAvailability();
}}
/>
<label className="form-check-label" htmlFor={`payload_${item.kind}`}>
{item.title}
</label>
</div>
);
})}
</div>
{payloadKind === PayloadKind.custom && (
<div className="lh-base">
<div className="form-text text-muted mb-3">
It's possible to customize the payload used to notify your service. This may help integrating
ArtifactHub webhooks with other services without requiring you to write any code. To integrate
ArtifactHub webhooks with Slack, for example, you could use a custom payload using the following
template:
<div className="my-3 w-100">
<div
className={`alert alert-light text-nowrap ${styles.codeWrapper}`}
role="alert"
aria-live="off"
aria-atomic="true"
>
{'{'}
<br />
<span className="ms-3">
{`"text": "Package`} <span className="fw-bold">{`{{ .Package.Name }}`}</span> {`version`}{' '}
<span className="fw-bold">{`{{ .Package.Version }}`}</span> released!{' '}
<span className="fw-bold">{`{{ .Package.URL }}`}</span>
{`"`}
<br />
{'}'}
</span>
</div>
</div>
</div>
</div>
)}
<div className="d-flex">
<div className="col-md-8">
<InputField
ref={contentTypeInput}
type="text"
label="Request Content-Type"
name="contentType"
value={contentType}
placeholder={payloadKind === PayloadKind.default ? 'application/cloudevents+json' : 'application/json'}
disabled={payloadKind === PayloadKind.default}
required={payloadKind !== PayloadKind.default}
invalidText={{
default: 'This field is required',
}}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
onContentTypeChange(e);
checkTestAvailability();
}}
/>
</div>
</div>
<div className=" mb-4">
<label className={`form-label fw-bold ${styles.label}`} htmlFor="template">
Template
</label>
{payloadKind === PayloadKind.custom && (
<div className="form-text text-muted mb-4 mt-0">
Custom payloads are generated using{' '}
<ExternalLink
href="https://golang.org/pkg/text/template/"
className="fw-bold text-dark"
label="Open Go templates documentation"
>
Go templates
</ExternalLink>
. Below you will find a list of the variables available for use in your template.
</div>
)}
<div className="row">
<div className="col col-xxl-10 col-xxxl-8">
<AutoresizeTextarea
name="template"
value={payloadKind === PayloadKind.default ? DEFAULT_PAYLOAD_TEMPLATE : template}
disabled={payloadKind === PayloadKind.default}
required={payloadKind !== PayloadKind.default}
invalidText="This field is required"
minRows={6}
onChange={updateTemplate}
/>
</div>
</div>
</div>
<div className="mb-3">
<label className={`form-label fw-bold ${styles.label}`} htmlFor="template">
Variables reference
</label>
<div className="row">
<div className="col col-xxxl-8 overflow-auto">
<small className={`text-muted ${styles.tableWrapper}`}>
<table className={`table table-sm border ${styles.variablesTable}`}>
<tbody>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .BaseURL }}`}</span>
</th>
<td>Artifact Hub deployment base url.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Event.ID }}`}</span>
</th>
<td>Id of the event triggering the notification.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Event.Kind }}`}</span>
</th>
<td>
Kind of the event triggering notification. Possible values are{' '}
<span className="fw-bold">package.new-release</span> and{' '}
<span className="fw-bold">package.security-alert</span>.
</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Name }}`}</span>
</th>
<td>Name of the package.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Version }}`}</span>
</th>
<td>Version of the new release.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.URL }}`}</span>
</th>
<td>ArtifactHub URL of the package.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Changes }}`}</span>
</th>
<td>List of changes this package version introduces.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Changes[i].Kind }}`}</span>
</th>
<td>
Kind of the change. Possible values are <span className="fw-bold">added</span>,{' '}
<span className="fw-bold">changed</span>, <span className="fw-bold">deprecated</span>,{' '}
<span className="fw-bold">removed</span>, <span className="fw-bold">fixed</span> and{' '}
<span className="fw-bold">security</span>. When the change kind is not provided, the value
will be empty.
</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Changes[i].Description }}`}</span>
</th>
<td>Brief text explaining the change.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Changes[i].Links }}`}</span>
</th>
<td>List of links related to the change.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Changes[i].Links[i].Name }}`}</span>
</th>
<td>Name of the link.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Changes[i].Links[i].URL }}`}</span>
</th>
<td>Url of the link.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.ContainsSecurityUpdates }}`}</span>
</th>
<td>Boolean flag that indicates whether this package contains security updates or not.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Prerelease }}`}</span>
</th>
<td>Boolean flag that indicates whether this package version is a pre-release or not.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Repository.Kind }}`}</span>
</th>
<td>
Kind of the repository associated with the notification. Possible values are{' '}
<span className="fw-bold">falco</span>, <span className="fw-bold">helm</span>,{' '}
<span className="fw-bold">olm</span> and <span className="fw-bold">opa</span>.
</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Repository.Name }}`}</span>
</th>
<td>Name of the repository.</td>
</tr>
<tr>
<th scope="row">
<span className="text-nowrap">{`{{ .Package.Repository.Publisher }}`}</span>
</th>
<td>
Publisher of the repository. If the owner is a user it'll be the user alias. If it's an
organization, it'll be the organization name.
</td>
</tr>
</tbody>
</table>
</small>
</div>
</div>
</div>
<div className={`mt-4 mt-md-5 ${styles.btnWrapper}`}>
<div className="d-flex flex-row justify-content-between">
<div className="d-flex flex-row align-items-center me-3">
<button
type="button"
className="btn btn-sm btn-success"
onClick={triggerTest}
disabled={!isAvailableTest || isSendingTest}
aria-label="Test webhook"
>
{isSendingTest ? (
<>
<span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
<span className="ms-2">
Testing <span className="d-none d-md-inline"> webhook</span>
</span>
</>
) : (
<div className="d-flex flex-row align-items-center text-uppercase">
<RiTestTubeFill className="me-2" />{' '}
<div>
Test <span className="d-none d-sm-inline-block">webhook</span>
</div>
</div>
)}
</button>
{isTestSent && (
<span className="text-success ms-2" data-testid="testWebhookTick">
<FaCheck />
</span>
)}
</div>
<div className="ms-auto">
<button
type="button"
className="btn btn-sm btn-outline-secondary me-3"
onClick={onCloseForm}
aria-label="Cancel"
>
<div className="d-flex flex-row align-items-center text-uppercase">
<MdClose className="me-2" />
<div>Cancel</div>
</div>
</button>
<button
className="btn btn-sm btn-outline-secondary"
type="button"
disabled={isSending}
onClick={submitForm}
aria-label="Add webhook"
>
{isSending ? (
<>
<span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
<span className="ms-2">{isUndefined(props.webhook) ? 'Adding' : 'Updating'} webhook</span>
</>
) : (
<div className="d-flex flex-row align-items-center text-uppercase">
{isUndefined(props.webhook) ? (
<>
<MdAddCircle className="me-2" />
<span>Add</span>
</>
) : (
<>
<FaPencilAlt className="me-2" />
<div>Save</div>
</>
)}
</div>
)}
</button>
</div>
</div>
<Alert message={apiError} type="danger" onClose={() => setApiError(null)} />
</div>
</form>
</div>
</div>
);
}
Example #12
Source File: index.tsx From hub with Apache License 2.0 | 4 votes |
WebhooksSection = (props: Props) => {
const history = useHistory();
const { ctx } = useContext(AppCtx);
const [isGettingWebhooks, setIsGettingWebhooks] = useState(false);
const [webhooks, setWebhooks] = useState<Webhook[] | undefined>(undefined);
const [apiError, setApiError] = useState<null | string>(null);
const [visibleForm, setVisibleForm] = useState<VisibleForm | null>(null);
const [activePage, setActivePage] = useState<number>(props.activePage ? parseInt(props.activePage) : 1);
const calculateOffset = (pageNumber?: number): number => {
return DEFAULT_LIMIT * ((pageNumber || activePage) - 1);
};
const [offset, setOffset] = useState<number>(calculateOffset());
const [total, setTotal] = useState<number | undefined>(undefined);
const onPageNumberChange = (pageNumber: number): void => {
setOffset(calculateOffset(pageNumber));
setActivePage(pageNumber);
};
const updatePageNumber = () => {
history.replace({
search: `?page=${activePage}`,
});
};
async function fetchWebhooks() {
try {
setIsGettingWebhooks(true);
const data = await API.getWebhooks(
{
limit: DEFAULT_LIMIT,
offset: offset,
},
ctx.prefs.controlPanel.selectedOrg
);
const total = parseInt(data.paginationTotalCount);
if (total > 0 && data.items.length === 0) {
onPageNumberChange(1);
} else {
setWebhooks(data.items);
setTotal(total);
}
updatePageNumber();
setApiError(null);
setIsGettingWebhooks(false);
} catch (err: any) {
setIsGettingWebhooks(false);
if (err.kind !== ErrorKind.Unauthorized) {
setWebhooks([]);
setApiError('An error occurred getting webhooks, please try again later.');
} else {
props.onAuthError();
}
}
}
useEffect(() => {
fetchWebhooks();
}, []); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (props.activePage && activePage !== parseInt(props.activePage)) {
fetchWebhooks();
}
}, [activePage]); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<div className="d-flex flex-column flex-grow-1">
<main role="main" className="p-0">
<div className="flex-grow-1">
{!isNull(visibleForm) ? (
<WebhookForm
onClose={() => setVisibleForm(null)}
onSuccess={fetchWebhooks}
webhook={visibleForm.webhook}
{...props}
/>
) : (
<>
<div>
<div className="d-flex flex-row align-items-center justify-content-between pb-2 border-bottom">
<div className={`h3 pb-0 ${styles.title}`}>Webhooks</div>
<div>
<button
className={`btn btn-outline-secondary btn-sm text-uppercase ${styles.btnAction}`}
onClick={() => setVisibleForm({ visible: true })}
aria-label="Open webhook form"
>
<div className="d-flex flex-row align-items-center justify-content-center">
<MdAdd className="d-inline d-md-none" />
<MdAddCircle className="d-none d-md-inline me-2" />
<span className="d-none d-md-inline">Add</span>
</div>
</button>
</div>
</div>
</div>
{(isGettingWebhooks || isUndefined(webhooks)) && <Loading />}
<div className="mt-4 mt-md-5">
<p className="m-0">Webhooks notify external services when certain events happen.</p>
{!isUndefined(webhooks) && (
<>
{webhooks.length === 0 ? (
<NoData issuesLinkVisible={!isNull(apiError)}>
{isNull(apiError) ? (
<>
<p className="h6 my-4 lh-base">
You have not created any webhook yet. You can create your first one by clicking on the
button below.
</p>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setVisibleForm({ visible: true })}
aria-label="Open form for creating your first webhook"
>
<div className="d-flex flex-row align-items-center text-uppercase">
<MdAddCircle className="me-2" />
<span>Add webhook</span>
</div>
</button>
</>
) : (
<>{apiError}</>
)}
</NoData>
) : (
<>
<div className="row mt-3 mt-md-4 gx-0 gx-xxl-4">
{webhooks.map((webhook: Webhook) => (
<WebhookCard
key={`webhook_${webhook.webhookId}`}
webhook={webhook}
onEdition={() => setVisibleForm({ visible: true, webhook: webhook })}
onAuthError={props.onAuthError}
onDeletion={fetchWebhooks}
/>
))}
</div>
{!isUndefined(total) && (
<div className="mx-auto">
<Pagination
limit={DEFAULT_LIMIT}
offset={offset}
total={total}
active={activePage}
className="my-5"
onChange={onPageNumberChange}
/>
</div>
)}
</>
)}
</>
)}
</div>
</>
)}
</div>
</main>
</div>
);
}