feat(repository): make the "RepositoryCreateForm" island completely interactive
+ 135
- 28
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1663465895278,
+  "_generatedAtUnix": 1663468360993,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

...
@@ -10,7 +10,7 @@
       "pathSourceMap": "./public/.islands/InstantRouterIndicator.bundle.js.map"
     },
     "RepositoryCreateForm": {
-      "hash": "538d1b96e2d3672a2071c79aae841b71dd3f9975",
+      "hash": "8ef66a3025d8c4f72d871d32035bbf47f8e47809",
       "pathSource": "./app/islands/RepositoryCreateForm.tsx",
       "pathBundle": "./public/.islands/RepositoryCreateForm.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryCreateForm.bundle.js.map"

...
@@ -44,7 +44,7 @@
       "pathSource": "./app/views/auth/RegisterView.tsx"
     },
     "RepositoryCreateView": {
-      "hash": "c3f2c6e08686690cb466628aa7d0715e99d9ba70",
+      "hash": "cb01e6394094a287f6084a43d556277abbbc5b05",
       "pathSource": "./app/views/repository/RepositoryCreateView.tsx"
     },
     "RepositoryExploreView": {

app/islands/RepositoryCreateForm.tsx
@@ -1,11 +1,13 @@
 // 1st-party
 import type { ReactIsland } from "@ethicdevs/react-monolith";
 // 3rd-party
-import React, { useCallback, useState } from "react";
+import React, { useCallback, useEffect, useState } from "react";
 // generated via script[generate:prisma]
 import type { Organization } from "@prisma/client";
 // app
+import { Button } from "../components/Button.styled";
 import { Grid } from "../components/Grid";
+import { slugify } from "../utils/shared";
 
 export interface RepositoryCreateFormProps {
   availableParentOrgs: Organization[];

...
@@ -20,11 +22,23 @@ export interface RepositoryCreateFormProps {
   };
 }
 
+const SHORT_DESCRIPTION_MAX_LENGTH = 140;
+
 const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
   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 || []),
   ]);

...
@@ -33,6 +47,48 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
 
   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) {

...
@@ -44,7 +100,7 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
     [setKeywords]
   );
 
-  const onRepoInitLicenseFileChange = useCallback(
+  const onRepoInitLicenseFileInputChange = useCallback(
     (ev: React.ChangeEvent<HTMLInputElement>) => {
       if (ev == null || ev.target == null || ev.target.value == null) {
         return undefined;

...
@@ -55,6 +111,20 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
     [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 (
     <div>
       <fieldset>

...
@@ -65,10 +135,10 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
             Owner Organization <span>(*)</span>:
           </label>
           <select
-            style={styles.inputMaxWidth}
             defaultValue={initialValues?.parent_org_slug}
             name={"parent_org_slug"}
             required
+            style={styles.inputMaxWidth}
           >
             {availableParentOrgs.map((org) => (
               <option key={org.id} value={org.slug}>

...
@@ -83,12 +153,13 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
             Repository Name <span>(*)</span>:
           </label>
           <input
-            style={styles.inputMaxWidth}
-            defaultValue={initialValues?.repo_display_name}
             name={"repo_display_name"}
+            onChange={onDisplayNameInputChange}
             placeholder={"i.e. My Super Project"}
             required
+            style={styles.inputMaxWidth}
             type={"text"}
+            value={displayName}
           />
         </Grid.Col>
         {/* Repository Slug */}

...
@@ -97,12 +168,13 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
             Repository Slug <span>(*)</span>:
           </label>
           <input
-            style={styles.inputMaxWidth}
-            defaultValue={initialValues?.repo_slug}
             name={"repo_slug"}
+            onChange={onSlugInputChange}
             placeholder={"i.e. my-super-project"}
             required
+            style={styles.inputMaxWidth}
             type={"text"}
+            value={slug}
           />
         </Grid.Col>
       </fieldset>

...
@@ -112,20 +184,25 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
         <Grid.Col fluid nowrap>
           <label htmlFor={"repo_short_description"}>Short Description:</label>
           <textarea
-            style={styles.inputMaxWidth}
-            defaultValue={initialValues?.repo_short_description}
+            maxLength={SHORT_DESCRIPTION_MAX_LENGTH}
             name={"repo_short_description"}
+            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>
           <label htmlFor={"repo_website_url"}>Website URL:</label>
           <input
-            style={styles.inputMaxWidth}
             defaultValue={initialValues?.repo_website_url}
             name={"repo_website_url"}
             placeholder={"i.e. https://www.super-project.com"}
+            style={styles.inputMaxWidth}
             type={"text"}
           />
         </Grid.Col>

...
@@ -134,17 +211,17 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
           <label htmlFor={"repo_keywords_add"}>Keywords:</label>
           <input type={"hidden"} name={"repo_keywords"} value={keywords} />
           <input
-            style={styles.inputMaxWidth}
             name={"repo_keywords_add"}
+            onChange={onKeywordsInputChange}
             placeholder={"Keywords separated by a coma (,)..."}
+            style={styles.inputMaxWidth}
             type={"text"}
-            onChange={onKeywordsInputChange}
           />
           {keywords.map((word, idx) => (
             <input
-              style={styles.inputMaxWidth}
-              key={[idx, word].join(":")}
               disabled
+              key={[idx, word].join(":")}
+              style={styles.inputMaxWidth}
               type={"text"}
               value={word}
             />

...
@@ -155,24 +232,24 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
         <legend>Repository setup</legend>
         {/* Initialise Read Me file? */}
         <Grid.Row fluid nowrap>
-          <label htmlFor={"repo_init_readme_file"} style={styles.inputMaxWidth}>
+          <label htmlFor={"repo_init_readme_file"} style={styles.labelFlexOne}>
             Initialize with empty README.md file?
           </label>
           <input
-            type={"checkbox"}
-            name={"repo_init_readme_file"}
             defaultChecked={false}
+            name={"repo_init_readme_file"}
+            type={"checkbox"}
           />
         </Grid.Row>
         <Grid.Row fluid nowrap>
           {/* Initialise License file? */}
-          <label htmlFor={"repo_init_readme_file"} style={styles.inputMaxWidth}>
+          <label htmlFor={"repo_init_readme_file"} style={styles.labelFlexOne}>
             Initialize with a LICENSE file?
           </label>
           <select
+            defaultValue={"MIT"}
             disabled={repoInitLicenseFileChecked === false}
             name={"repo_init_license_kind"}
-            defaultValue={"MIT"}
           >
             <option key={"license:mit"} value={"mit"}>
               MIT License

...
@@ -188,21 +265,42 @@ const RepositoryCreateForm: ReactIsland<RepositoryCreateFormProps> = ({
             </option>
           </select>
           <input
-            type={"checkbox"}
-            name={"repo_init_license_file"}
-            onChange={onRepoInitLicenseFileChange}
             checked={repoInitLicenseFileChecked}
+            name={"repo_init_license_file"}
+            onChange={onRepoInitLicenseFileInputChange}
+            type={"checkbox"}
           />
         </Grid.Row>
       </fieldset>
+      {/* Submit Button */}
+      <Button type={"submit"}>Create Repository</Button>
     </div>
   );
 };
 
 const styles = {
+  alignSelfEnd: {
+    alignSelf: "flex-end",
+  },
   inputMaxWidth: {
+    minWidth: "100%",
     width: "100%",
+    maxWidth: "100%",
+  },
+  labelFlexOne: {
+    flex: 1,
+    marginRight: 8,
   },
+  shortDescriptionTextArea: {
+    minHeight: 75,
+    height: 75,
+    maxHeight: 75,
+  },
+};
+
+const shortDescriptionStyles = {
+  ...styles.inputMaxWidth,
+  ...styles.shortDescriptionTextArea,
 };
 
 RepositoryCreateForm.displayName = "RepositoryCreateForm";

new file
app/utils/shared/index.ts
@@ -0,0 +1 @@
+export { default as slugify } from "./slugify";

new file
app/utils/shared/slugify.ts
@@ -0,0 +1,10 @@
+export default function slugify(str: string): string {
+  return str
+    .toString()
+    .toLowerCase()
+    .replace(/\s+/g, "-") // Replace spaces with -
+    .replace(/[^\w\-]+/g, "") // Remove all non-word chars
+    .replace(/\-\-+/g, "-") // Replace multiple - with single -
+    .replace(/^-+/, "") // Trim - from start of text
+    .replace(/-+$/, ""); // Trim - from end of text
+}

app/views/repository/RepositoryCreateView.tsx
@@ -6,7 +6,7 @@ import React from "react";
 import { Organization } from "@prisma/client";
 // app
 import type { CommonProps } from "../../types";
-import { Button, Layout, PageWrapper } from "../../components";
+import { Layout, PageWrapper } from "../../components";
 // app islands
 import RepositoryCreateForm from "../../islands/RepositoryCreateForm";
 

...
@@ -44,8 +44,6 @@ const RepositoryCreateView: ReactView<RepositoryCreateViewProps> = ({
               initialValues={initialValues}
             />
           </div>
-          {/* Submit Button */}
-          <Button type={"submit"}>Create Repository</Button>
         </form>
       </PageWrapper>
     </Layout>