GitFOSS
fe04f67 (parent 5c6496d)5/9/2026, 8:25:56 AM
.ts
TypeScript
(application/typescript)
// 1st-party
import type { ReactIsland } from "@ethicdevs/react-monolith";
// 3rd-party
import React, { useCallback, useEffect, useState } from "react";
// generated via script[generate:prisma]
import type { Organization } from "@prisma/client";
// app
import type { WithThemeSchemeProp } from "../types";
import { slugify } from "../utils/shared";
import { Button } from "../components/Button.styled";
import { Card } from "../components/Card.styled";
import { Grid } from "../components/Grid";
import { Select, TextArea, TextInput } from "../components/TextInput.styled";

export interface RepositoryCreateFormProps extends WithThemeSchemeProp {
  availableParentOrgs: Organization[];
  editMode?: boolean;
  initialValues?: {
    parent_org_slug?: string;
    repo_slug?: string;
    repo_display_name?: string;
    repo_short_description?: string;
    repo_website_url?: string;
    repo_keywords?: string[];
  };
}

const SHORT_DESCRIPTION_MAX_LENGTH = 140;

const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
  themeScheme,
  availableParentOrgs,
  editMode = false,
  initialValues = undefined,
}) => {
  const [displayName, setDisplayName] = useState<string>(
    initialValues?.repo_display_name || "",
  );
  const [slug, setSlug] = useState<string>(
    initialValues?.repo_slug || slugify(initialValues?.repo_display_name || ""),
  );
  const [slugInputDirty, setSlugInputDirty] = useState<boolean>(false);
  const [shortDescription, setShortDescription] = useState<string>(
    initialValues?.repo_short_description || "",
  );
  const [keywords, setKeywords] = useState<string[]>([
    ...(initialValues?.repo_keywords || []),
  ]);
  const [repoInitLicenseFileChecked, setRepoInitLicenseFileChecked] =
    useState<boolean>(false);

  editMode;

  const onDisplayNameInputChange = useCallback(
    (ev: React.ChangeEvent<HTMLInputElement>) => {
      if (ev == null || ev.target == null || ev.target.value == null) {
        return undefined;
      }
      setDisplayName(ev.target.value);
      return undefined;
    },
    [setDisplayName],
  );

  const onSlugInputChange = useCallback(
    (ev: React.ChangeEvent<HTMLInputElement>) => {
      if (ev == null || ev.target == null || ev.target.value == null) {
        return undefined;
      }
      setSlug(ev.target.value);
      // only once, avoid useless re-renders for nothing
      if (slugInputDirty === false) {
        setSlugInputDirty(true);
      }
      return undefined;
    },
    [slugInputDirty, setSlug, setSlugInputDirty],
  );

  const onShortDescriptionTextAreaChange = useCallback(
    (ev: React.ChangeEvent<HTMLTextAreaElement>) => {
      if (
        ev == null ||
        ev.target == null ||
        ev.target.value == null ||
        ev.target.value.length > SHORT_DESCRIPTION_MAX_LENGTH
      ) {
        return undefined;
      }
      setShortDescription(ev.target.value);
      return undefined;
    },
    [setShortDescription],
  );

  const onKeywordsInputChange = useCallback(
    (ev: React.ChangeEvent<HTMLInputElement>) => {
      if (ev == null || ev.target == null || ev.target.value == null) {
        return undefined;
      }
      setKeywords(ev.target.value.split(",").map((w) => w.trim()));
      return undefined;
    },
    [setKeywords],
  );

  const onRepoInitLicenseFileInputChange = useCallback(
    (ev: React.ChangeEvent<HTMLInputElement>) => {
      if (ev == null || ev.target == null || ev.target.value == null) {
        return undefined;
      }
      setRepoInitLicenseFileChecked(ev.target.checked);
      return undefined;
    },
    [setRepoInitLicenseFileChecked],
  );

  // An effect that will update the slug if it isn't dirty when the display name
  // input value (displayName) has changed.
  useEffect(() => {
    const nextSlug = slugify(displayName);
    if (
      slugInputDirty === false &&
      displayName != null &&
      // displayName.trim() !== "" &&
      nextSlug !== slug
    ) {
      setSlug(nextSlug);
    }
  }, [displayName, slug, slugInputDirty, setSlug]);

  return (
    <Grid.Col fluid nowrap gap={24}>
      <Grid.Col fluid nowrap gap={8}>
        <label>
          <strong>Repository details</strong>
        </label>
        <Card themeScheme={themeScheme} style={{ gap: 16 }}>
          {/* Repository Name */}
          <Grid.Col fluid nowrap gap={4}>
            <label htmlFor={"repo_display_name"}>
              Repository Name<span style={{ color: "red" }}>*</span>:
            </label>
            <TextInput
              themeScheme={themeScheme}
              name={"repo_display_name"}
              onChange={onDisplayNameInputChange}
              placeholder={"i.e. My Super Project"}
              required
              style={styles.inputMaxWidth}
              type={"text"}
              value={displayName}
            />
          </Grid.Col>
          <Grid.Row fluid nowrap gap={16} alignItems={"center"}>
            {/* Parent Organization Select */}
            <Grid.Col fluid nowrap gap={4}>
              <label htmlFor={"repo_display_name"}>
                Owner Organization<span style={{ color: "red" }}>*</span>:
              </label>
              <Select
                required
                themeScheme={themeScheme}
                name={"parent_org_slug"}
                defaultValue={
                  availableParentOrgs.length >= 1
                    ? availableParentOrgs[0].slug
                    : initialValues?.parent_org_slug
                }
                style={styles.inputMaxWidth}
              >
                {availableParentOrgs.map((org) => (
                  <option key={org.id} value={org.slug}>
                    {org.displayName || org.slug}
                  </option>
                ))}
              </Select>
            </Grid.Col>
            {/* Repository Slug */}
            <Grid.Col fluid nowrap gap={4}>
              <label htmlFor={"repo_slug"}>
                Repository Slug<span style={{ color: "red" }}>*</span>:
              </label>
              <TextInput
                themeScheme={themeScheme}
                name={"repo_slug"}
                onChange={onSlugInputChange}
                placeholder={"i.e. my-super-project"}
                required
                style={styles.inputMaxWidth}
                type={"text"}
                value={slug}
              />
            </Grid.Col>
          </Grid.Row>
          {/* Repository Visibility */}
          <Grid.Col fluid nowrap gap={4}>
            <label htmlFor={"repo_visibility"}>
              Repository Visibility<span style={{ color: "red" }}>*</span>:
            </label>
            <Select
              themeScheme={themeScheme}
              name={"repo_visibility"}
              defaultValue={"PRIVATE"}
              style={styles.inputMaxWidth}
            >
              <option key={"private"} value={"PRIVATE"}>
                Private
              </option>
              <option key={"unlisted"} value={"UNLISTED"}>
                Unlisted
              </option>
              <option key={"public"} value={"PUBLIC"}>
                Public
              </option>
            </Select>
          </Grid.Col>
        </Card>
      </Grid.Col>
      <Grid.Col fluid nowrap gap={8}>
        <label>
          <strong>Repository description</strong>
        </label>
        <Card themeScheme={themeScheme} style={{ gap: 16 }}>
          {/* Short Description */}
          <Grid.Col fluid nowrap gap={4}>
            <label htmlFor={"repo_short_description"}>Short Description:</label>
            <TextArea
              themeScheme={themeScheme}
              name={"repo_short_description"}
              maxLength={SHORT_DESCRIPTION_MAX_LENGTH}
              onChange={onShortDescriptionTextAreaChange}
              placeholder={"i.e. A super project about things that are super!"}
              style={shortDescriptionStyles}
              value={shortDescription}
            ></TextArea>
            <span style={styles.alignSelfEnd}>
              {shortDescription.length}/{SHORT_DESCRIPTION_MAX_LENGTH}
            </span>
          </Grid.Col>
          {/* Website URL */}
          <Grid.Col fluid nowrap gap={4}>
            <label htmlFor={"repo_website_url"}>Website URL:</label>
            <TextInput
              themeScheme={themeScheme}
              name={"repo_website_url"}
              defaultValue={initialValues?.repo_website_url}
              placeholder={"i.e. https://www.super-project.com"}
              style={styles.inputMaxWidth}
              type={"text"}
            />
          </Grid.Col>
          {/* Keywords */}
          <Grid.Col fluid nowrap gap={4}>
            <label htmlFor={"repo_keywords_add"}>Keywords:</label>
            <input type={"hidden"} name={"repo_keywords"} value={keywords} />
            <TextInput
              themeScheme={themeScheme}
              name={"repo_keywords_add"}
              onChange={onKeywordsInputChange}
              placeholder={"Keywords separated by a coma (,)..."}
              style={styles.inputMaxWidth}
              type={"text"}
            />
            {keywords.map((word, idx) => (
              <TextInput
                themeScheme={themeScheme}
                disabled
                key={[idx, word].join(":")}
                style={styles.inputMaxWidth}
                type={"text"}
                value={word}
              />
            ))}
          </Grid.Col>
        </Card>
      </Grid.Col>
      <Grid.Col fluid nowrap gap={8}>
        <label>
          <strong>Repository setup</strong>
        </label>
        <Card themeScheme={themeScheme} style={{ gap: 16 }}>
          {/* Initialise Read Me file? */}
          <Grid.Row fluid nowrap gap={16}>
            <label
              htmlFor={"repo_init_readme_file"}
              style={styles.labelFlexOne}
            >
              Initialize with empty README.md file?
            </label>
            <input
              defaultChecked={false}
              name={"repo_init_readme_file"}
              type={"checkbox"}
            />
          </Grid.Row>
          {/* Initialise License file? */}
          <Grid.Row fluid nowrap gap={16} alignItems={"center"}>
            <label
              htmlFor={"repo_init_readme_file"}
              style={styles.labelFlexOne}
            >
              Initialize with a LICENSE file?
            </label>
            <Select
              themeScheme={themeScheme}
              name={"repo_init_license_kind"}
              defaultValue={"MIT"}
              disabled={repoInitLicenseFileChecked === false}
              style={{ width: "auto" }}
            >
              <option key={"license:mit"} value={"mit"}>
                MIT License
              </option>
              <option key={"license:gnu-gpl-v3"} value={"gnu-gpl-v3"}>
                GNU GPL v.3.0
              </option>
              <option key={"license:gnu-agpl-v3"} value={"gnu-agpl-v3"}>
                AGPL v.3.0
              </option>
              <option key={"license:gnu-lgpl-v3"} value={"gnu-lgpl-v3"}>
                LGPL v.3.0
              </option>
            </Select>
            <input
              checked={repoInitLicenseFileChecked}
              name={"repo_init_license_file"}
              onChange={onRepoInitLicenseFileInputChange}
              type={"checkbox"}
            />
          </Grid.Row>
        </Card>
      </Grid.Col>

      {/* Submit Button */}
      <Button style={styles.inputMaxWidth} type={"submit"}>
        Create Repository
      </Button>
    </Grid.Col>
  );
};

const styles = {
  alignSelfEnd: {
    alignSelf: "flex-end",
  },
  inputMaxWidth: {
    minWidth: "100%",
    width: "100%",
    maxWidth: "100%",
  },
  labelFlexOne: {
    flex: 1,
    marginRight: 16,
  },
  shortDescriptionTextArea: {
    minHeight: 75,
    height: 75,
    maxHeight: 75,
  },
};

const shortDescriptionStyles = {
  ...styles.inputMaxWidth,
  ...styles.shortDescriptionTextArea,
};

RepositoryCreateForm.displayName = "RepositoryCreateForm";
export default RepositoryCreateForm;