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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
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>
  );
}