lightnet 3.10.5 → 3.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/__e2e__/admin.spec.ts +29 -48
  3. package/__e2e__/{test-utils.ts → basics-fixture.ts} +20 -23
  4. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  5. package/__e2e__/fixtures/basics/package.json +2 -2
  6. package/__e2e__/fixtures/basics/src/content/media/faithful-freestyle--en.json +1 -1
  7. package/__e2e__/fixtures/basics/src/content/media/skate-sounds--en.json +1 -1
  8. package/__e2e__/global.teardown.ts +5 -0
  9. package/__e2e__/homepage.spec.ts +17 -19
  10. package/__e2e__/search.spec.ts +3 -5
  11. package/__tests__/utils/markdown.spec.ts +16 -0
  12. package/package.json +6 -5
  13. package/playwright.config.ts +1 -0
  14. package/src/admin/components/form/DynamicArray.tsx +9 -6
  15. package/src/admin/components/form/Input.tsx +31 -13
  16. package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +102 -0
  17. package/src/admin/components/form/MarkdownEditor.tsx +54 -0
  18. package/src/admin/components/form/Select.tsx +24 -6
  19. package/src/admin/components/form/SubmitButton.tsx +11 -6
  20. package/src/admin/components/form/atoms/Hint.tsx +11 -2
  21. package/src/admin/components/form/atoms/Label.tsx +14 -11
  22. package/src/admin/components/form/hooks/use-field-dirty.tsx +12 -0
  23. package/src/admin/i18n/translations/en.yml +9 -8
  24. package/src/admin/pages/media/EditForm.tsx +29 -6
  25. package/src/admin/pages/media/EditRoute.astro +3 -3
  26. package/src/admin/pages/media/fields/Authors.tsx +16 -25
  27. package/src/admin/pages/media/fields/Categories.tsx +8 -35
  28. package/src/admin/pages/media/fields/Collections.tsx +25 -70
  29. package/src/admin/types/media-item.ts +1 -0
  30. package/src/components/HighlightSection.astro +1 -1
  31. package/src/pages/details-page/components/main-details/OpenButton.astro +1 -1
  32. package/src/utils/markdown.ts +6 -0
  33. package/src/admin/components/form/atoms/Legend.tsx +0 -20
@@ -0,0 +1,102 @@
1
+ import "@mdxeditor/editor/style.css"
2
+
3
+ import {
4
+ BlockTypeSelect,
5
+ BoldItalicUnderlineToggles,
6
+ type CodeBlockEditorProps,
7
+ codeBlockPlugin,
8
+ CreateLink,
9
+ headingsPlugin,
10
+ linkDialogPlugin,
11
+ linkPlugin,
12
+ listsPlugin,
13
+ ListsToggle,
14
+ markdownShortcutPlugin,
15
+ MDXEditor,
16
+ quotePlugin,
17
+ toolbarPlugin,
18
+ UndoRedo,
19
+ useCodeBlockEditorContext,
20
+ } from "@mdxeditor/editor"
21
+ import {
22
+ type Control,
23
+ Controller,
24
+ type FieldValues,
25
+ type Path,
26
+ } from "react-hook-form"
27
+
28
+ /**
29
+ * IMPORTANT: Do not import this component directly. It is
30
+ * very big. Use it with React lazy loading.
31
+ */
32
+ export default function LazyLoadedMarkdownEditor<
33
+ TFieldValues extends FieldValues,
34
+ >({
35
+ control,
36
+ name,
37
+ }: {
38
+ name: Path<TFieldValues>
39
+ control: Control<TFieldValues>
40
+ }) {
41
+ return (
42
+ <Controller
43
+ control={control}
44
+ name={name}
45
+ render={({ field: { onBlur, onChange, value, ref } }) => (
46
+ <MDXEditor
47
+ markdown={value ?? ""}
48
+ onBlur={onBlur}
49
+ onChange={onChange}
50
+ contentEditableClassName="prose bg-gray-50 h-80 w-full max-w-full overflow-y-auto"
51
+ ref={ref}
52
+ onError={(error) =>
53
+ console.error("Error while editing markdown", error)
54
+ }
55
+ plugins={[
56
+ headingsPlugin(),
57
+ listsPlugin(),
58
+ linkPlugin(),
59
+ codeBlockPlugin({
60
+ codeBlockEditorDescriptors: [
61
+ {
62
+ match: () => true,
63
+ priority: 0,
64
+ Editor: TextAreaCodeEditor,
65
+ },
66
+ ],
67
+ }),
68
+ linkDialogPlugin(),
69
+ quotePlugin(),
70
+ markdownShortcutPlugin(),
71
+ toolbarPlugin({
72
+ toolbarContents: () => (
73
+ <>
74
+ <UndoRedo />
75
+ <BoldItalicUnderlineToggles />
76
+ <BlockTypeSelect />
77
+ <ListsToggle options={["bullet", "number"]} />
78
+ <CreateLink />
79
+ </>
80
+ ),
81
+ toolbarClassName: "!rounded-none",
82
+ }),
83
+ ]}
84
+ />
85
+ )}
86
+ />
87
+ )
88
+ }
89
+
90
+ function TextAreaCodeEditor(props: CodeBlockEditorProps) {
91
+ const cb = useCodeBlockEditorContext()
92
+ return (
93
+ <div onKeyDown={(e) => e.nativeEvent.stopImmediatePropagation()}>
94
+ <textarea
95
+ rows={3}
96
+ cols={20}
97
+ defaultValue={props.code}
98
+ onChange={(e) => cb.setCode(e.target.value)}
99
+ />
100
+ </div>
101
+ )
102
+ }
@@ -0,0 +1,54 @@
1
+ import { lazy, Suspense } from "react"
2
+ import { type Control, type FieldValues, type Path } from "react-hook-form"
3
+
4
+ import ErrorMessage from "./atoms/ErrorMessage"
5
+ import Hint from "./atoms/Hint"
6
+ import Label from "./atoms/Label"
7
+ import { useFieldDirty } from "./hooks/use-field-dirty"
8
+ import { useFieldError } from "./hooks/use-field-error"
9
+
10
+ const LazyLoadedMarkdownEditor = lazy(
11
+ () => import("./LazyLoadedMarkdownEditor"),
12
+ )
13
+
14
+ export default function MarkdownEditor<TFieldValues extends FieldValues>({
15
+ control,
16
+ name,
17
+ label,
18
+ hint,
19
+ }: {
20
+ name: Path<TFieldValues>
21
+ label: string
22
+ hint?: string
23
+ control: Control<TFieldValues>
24
+ }) {
25
+ const isDirty = useFieldDirty({ control, name })
26
+ const errorMessage = useFieldError({ control, name })
27
+
28
+ return (
29
+ <fieldset key={name} className="group">
30
+ <legend>
31
+ <Label label={label} isDirty={isDirty} isInvalid={!!errorMessage} />
32
+ </legend>
33
+
34
+ <div
35
+ className={`overflow-hidden rounded-lg rounded-ss-none border border-gray-300 shadow-sm group-focus-within:border-blue-700 group-focus-within:ring-1 group-focus-within:ring-blue-700 ${isDirty && !errorMessage ? "border-gray-700" : ""} ${errorMessage ? "border-rose-800" : ""}`}
36
+ >
37
+ <Suspense
38
+ fallback={
39
+ <div className="h-[22.75rem] w-full bg-gray-50">
40
+ <div className="h-10 bg-gray-100"></div>
41
+ </div>
42
+ }
43
+ >
44
+ <LazyLoadedMarkdownEditor
45
+ control={control as Control<any>}
46
+ name={name}
47
+ />
48
+ </Suspense>
49
+ </div>
50
+ <ErrorMessage message={errorMessage} />
51
+ <Hint preserveSpace={true} label={hint} />
52
+ </fieldset>
53
+ )
54
+ }
@@ -1,32 +1,50 @@
1
- import type { Control, FieldValues, Path } from "react-hook-form"
1
+ import { type Control, type FieldValues, type Path } from "react-hook-form"
2
2
 
3
3
  import ErrorMessage from "./atoms/ErrorMessage"
4
4
  import Hint from "./atoms/Hint"
5
5
  import Label from "./atoms/Label"
6
+ import { useFieldDirty } from "./hooks/use-field-dirty"
6
7
  import { useFieldError } from "./hooks/use-field-error"
7
8
 
8
9
  export default function Select<TFieldValues extends FieldValues>({
9
10
  name,
10
11
  label,
12
+ labelSize,
11
13
  control,
14
+ defaultValue,
12
15
  hint,
16
+ preserveHintSpace = true,
13
17
  options,
14
18
  }: {
15
19
  name: Path<TFieldValues>
16
- label: string
20
+ label?: string
21
+ labelSize?: "small" | "medium"
17
22
  hint?: string
23
+ preserveHintSpace?: boolean
24
+ defaultValue?: string
18
25
  control: Control<TFieldValues>
19
26
  options: { id: string; labelText?: string }[]
20
27
  }) {
28
+ const isDirty = useFieldDirty({ control, name })
21
29
  const errorMessage = useFieldError({ control, name })
22
30
  return (
23
- <div key={name} className="flex w-full flex-col">
24
- <Label for={name} label={label} />
31
+ <div key={name} className="group flex w-full flex-col">
32
+ {label && (
33
+ <label htmlFor={name}>
34
+ <Label
35
+ label={label}
36
+ size={labelSize}
37
+ isDirty={isDirty}
38
+ isInvalid={!!errorMessage}
39
+ />
40
+ </label>
41
+ )}
25
42
  <select
26
43
  {...control.register(name)}
27
44
  id={name}
28
45
  aria-invalid={!!errorMessage}
29
- className={`dy-select dy-select-bordered text-base shadow-sm ${errorMessage ? "dy-select-error" : ""}`}
46
+ defaultValue={defaultValue}
47
+ className={`dy-select dy-select-bordered text-base shadow-sm focus:border-blue-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-700 ${isDirty && !errorMessage ? "border-gray-700" : ""} ${errorMessage ? "border-rose-800" : ""} ${label ? "rounded-ss-none" : ""}`}
30
48
  >
31
49
  {options.map(({ id, labelText }) => (
32
50
  <option key={id} value={id}>
@@ -35,7 +53,7 @@ export default function Select<TFieldValues extends FieldValues>({
35
53
  ))}
36
54
  </select>
37
55
  <ErrorMessage message={errorMessage} />
38
- <Hint label={hint} />
56
+ <Hint preserveSpace={preserveHintSpace} label={hint} />
39
57
  </div>
40
58
  )
41
59
  }
@@ -8,10 +8,10 @@ import type { MediaItem } from "../../types/media-item"
8
8
  const SUCCESS_DURATION_MS = 2000
9
9
 
10
10
  const baseButtonClass =
11
- "flex min-w-52 items-center justify-center gap-2 rounded-2xl px-4 py-3 font-bold uppercase shadow-sm transition-colors easy-in-out focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 disabled:cursor-not-allowed"
11
+ "flex min-w-52 items-center justify-center gap-2 rounded-2xl px-4 py-3 font-bold shadow-sm transition-colors easy-in-out focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-blue-700 focus-visible:ring-offset-1 disabled:cursor-not-allowed"
12
12
 
13
13
  const buttonStateClasses = {
14
- idle: "bg-gray-800 text-gray-100 hover:bg-gray-950 hover:text-gray-300 disabled:bg-gray-600 disabled:text-gray-200",
14
+ idle: "bg-gray-800 text-gray-50 hover:bg-gray-950 hover:text-gray-300 disabled:bg-gray-500 disabled:text-gray-300",
15
15
  error:
16
16
  "bg-rose-700 text-white hover:bg-rose-800 hover:text-white disabled:bg-rose-600",
17
17
  success:
@@ -40,9 +40,10 @@ export default function SubmitButton({
40
40
  className?: string
41
41
  }) {
42
42
  const { t } = useI18n()
43
- const { isSubmitting, isSubmitSuccessful, submitCount } = useFormState({
44
- control,
45
- })
43
+ const { isSubmitting, isSubmitSuccessful, submitCount, isDirty } =
44
+ useFormState({
45
+ control,
46
+ })
46
47
 
47
48
  const buttonState = useButtonState(isSubmitSuccessful, submitCount)
48
49
  const buttonClass = `${baseButtonClass} ${buttonStateClasses[buttonState]} ${className}`
@@ -50,7 +51,11 @@ export default function SubmitButton({
50
51
  const icon = icons[buttonState]
51
52
 
52
53
  return (
53
- <button className={buttonClass} type="submit" disabled={isSubmitting}>
54
+ <button
55
+ className={buttonClass}
56
+ type="submit"
57
+ disabled={isSubmitting || !isDirty}
58
+ >
54
59
  {icon && <Icon className={icon} ariaLabel="" />}
55
60
  {t(label)}
56
61
  </button>
@@ -1,9 +1,18 @@
1
1
  import { useI18n } from "../../../../i18n/react/useI18n"
2
2
 
3
- export default function Hint({ label }: { label?: string }) {
3
+ export default function Hint({
4
+ label,
5
+ preserveSpace,
6
+ }: {
7
+ label?: string
8
+ preserveSpace: boolean
9
+ }) {
4
10
  const { t } = useI18n()
11
+ if (!preserveSpace && !label) {
12
+ return null
13
+ }
5
14
  return (
6
- <div className="flex h-12 w-full items-start justify-end p-2">
15
+ <div className="flex h-8 w-full items-start justify-end p-2">
7
16
  {label && <span className="dy-label-text-alt">{t(label)}</span>}
8
17
  </div>
9
18
  )
@@ -2,22 +2,25 @@ import { useI18n } from "../../../../i18n/react/useI18n"
2
2
 
3
3
  export default function Label({
4
4
  label,
5
- for: htmlFor,
6
- size = "sm",
7
- className,
5
+ size = "medium",
6
+ className = "",
7
+ isDirty,
8
+ isInvalid,
8
9
  }: {
9
10
  label: string
10
- for: string
11
11
  className?: string
12
- size?: "sm" | "xs"
12
+ size?: "small" | "medium"
13
+ isDirty?: boolean
14
+ isInvalid?: boolean
13
15
  }) {
14
16
  const { t } = useI18n()
15
17
  return (
16
- <label
17
- htmlFor={htmlFor}
18
- className={`font-bold uppercase text-gray-500 ${size === "sm" ? "pb-2 text-sm" : "pb-1 text-xs"} ${className}`}
19
- >
20
- {t(label)}
21
- </label>
18
+ <div className="flex">
19
+ <span
20
+ className={`rounded-t-md bg-gray-300 px-4 font-bold text-gray-700 shadow-sm transition-colors duration-150 group-focus-within:bg-blue-700 group-focus-within:text-gray-50 group-focus-within:ring-1 group-focus-within:ring-blue-700 ${size === "medium" ? "py-2 text-sm" : "py-1 text-xs"} ${isDirty ? "bg-gray-700 !text-white" : ""} ${isInvalid ? "bg-rose-800 !text-gray-50" : ""} ${className}`}
21
+ >
22
+ {t(label)}
23
+ </span>
24
+ </div>
22
25
  )
23
26
  }
@@ -0,0 +1,12 @@
1
+ import { type Control, get, useFormState } from "react-hook-form"
2
+
3
+ export function useFieldDirty({
4
+ control,
5
+ name,
6
+ }: {
7
+ control: Control<any>
8
+ name: string
9
+ }) {
10
+ const { dirtyFields } = useFormState({ control, name, exact: true })
11
+ return get(dirtyFields, name) as boolean | undefined
12
+ }
@@ -1,23 +1,24 @@
1
1
  ln.admin.edit: Edit
2
- ln.admin.publish-changes: Publish Changes
2
+ ln.admin.publish-changes: Publish changes
3
3
  ln.admin.published: Published
4
- ln.admin.save-changes: Save Changes
4
+ ln.admin.save-changes: Save changes
5
5
  ln.admin.saved: Saved
6
6
  ln.admin.failed: Failed
7
7
  ln.admin.remove: Remove
8
8
  ln.admin.name: Name
9
- ln.admin.add-author: Add Author
10
- ln.admin.add-category: Add Category
9
+ ln.admin.add-author: Add author
10
+ ln.admin.add-category: Add category
11
11
  ln.admin.collections: Collections
12
- ln.admin.add-collection: Add Collection
13
- ln.admin.edit-media-item: Edit media item
12
+ ln.admin.add-collection: Add collection
13
+ ln.admin.edit-media-item: Edit Media Item
14
14
  ln.admin.position-in-collection: Position in Collection
15
15
  ln.admin.back-to-details-page: Back to details page
16
16
  ln.admin.title: Title
17
17
  ln.admin.common-id: Common ID
18
18
  ln.admin.authors: Authors
19
- ln.admin.created-on: Created on
20
- ln.admin.created-on-hint: When has this item been created on this library?
19
+ ln.admin.description: Description
20
+ ln.admin.date-created: Date Created
21
+ ln.admin.date-created-hint: When has this item been created on this library?
21
22
  ln.admin.common-id-hint: The English title, all lowercase, words separated with hyphens.
22
23
  ln.admin.errors.non-empty-string: Please enter at least one character.
23
24
  ln.admin.errors.invalid-date: That date doesn't look right.
@@ -7,6 +7,7 @@ import {
7
7
  I18nContext,
8
8
  } from "../../../i18n/react/i18n-context"
9
9
  import Input from "../../components/form/Input"
10
+ import MarkdownEditor from "../../components/form/MarkdownEditor"
10
11
  import Select from "../../components/form/Select"
11
12
  import SubmitButton from "../../components/form/SubmitButton"
12
13
  import { type MediaItem, mediaItemSchema } from "../../types/media-item"
@@ -52,35 +53,57 @@ export default function EditForm({
52
53
  <SubmitButton control={control} />
53
54
  </div>
54
55
 
55
- <Input name="title" label="ln.admin.title" control={control} />
56
+ <Input
57
+ name="title"
58
+ label="ln.admin.title"
59
+ control={control}
60
+ defaultValue={mediaItem.title}
61
+ />
56
62
  <Input
57
63
  name="commonId"
58
64
  label="ln.admin.common-id"
59
65
  hint="ln.admin.common-id-hint"
60
66
  control={control}
67
+ defaultValue={mediaItem.commonId}
61
68
  />
62
69
  <Select
63
70
  name="type"
64
71
  label="ln.type"
65
72
  options={mediaTypes}
66
73
  control={control}
74
+ defaultValue={mediaItem.type}
67
75
  />
68
76
  <Select
69
77
  name="language"
70
78
  label="ln.language"
79
+ defaultValue={mediaItem.language}
71
80
  options={languages}
72
81
  control={control}
73
82
  />
74
- <Authors control={control} />
83
+ <Authors control={control} defaultValue={mediaItem.authors} />
75
84
  <Input
76
85
  name="dateCreated"
77
- label="ln.admin.created-on"
78
- hint="ln.admin.created-on-hint"
86
+ label="ln.admin.date-created"
87
+ hint="ln.admin.date-created-hint"
79
88
  type="date"
89
+ defaultValue={mediaItem.dateCreated}
90
+ control={control}
91
+ />
92
+ <Categories
93
+ categories={categories}
94
+ control={control}
95
+ defaultValue={mediaItem.categories}
96
+ />
97
+ <Collections
98
+ collections={collections}
99
+ control={control}
100
+ defaultValue={mediaItem.collections}
101
+ />
102
+ <MarkdownEditor
80
103
  control={control}
104
+ name="description"
105
+ label="ln.admin.description"
81
106
  />
82
- <Categories categories={categories} control={control} />
83
- <Collections collections={collections} control={control} />
84
107
 
85
108
  <SubmitButton className="self-end" control={control} />
86
109
  </form>
@@ -58,11 +58,11 @@ const languages = config.languages.map(({ code, label }) => ({
58
58
  }))
59
59
  ---
60
60
 
61
- <Page mainClass="bg-slate-500">
61
+ <Page mainClass="bg-gray-700">
62
62
  <div class="mx-auto block max-w-screen-md px-4 md:px-8">
63
63
  <a
64
- class="block py-4 text-gray-200 underline"
65
- href=`/${Astro.currentLocale}/media/faithful-freestyle--en`
64
+ class="block pb-4 pt-8 text-gray-200 underline"
65
+ href=`/${Astro.currentLocale}/media/${mediaId}`
66
66
  >{t("ln.admin.back-to-details-page")}</a
67
67
  >
68
68
  </div>
@@ -1,17 +1,29 @@
1
1
  import { type Control } from "react-hook-form"
2
2
 
3
- import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
4
3
  import DynamicArray from "../../../components/form/DynamicArray"
5
- import { useFieldError } from "../../../components/form/hooks/use-field-error"
4
+ import Input from "../../../components/form/Input"
6
5
  import type { MediaItem } from "../../../types/media-item"
7
6
 
8
- export default function Authors({ control }: { control: Control<MediaItem> }) {
7
+ export default function Authors({
8
+ control,
9
+ defaultValue,
10
+ }: {
11
+ control: Control<MediaItem>
12
+ defaultValue: MediaItem["authors"]
13
+ }) {
9
14
  return (
10
15
  <DynamicArray
11
16
  control={control}
12
17
  name="authors"
13
18
  label="ln.admin.authors"
14
- renderElement={(index) => <AuthorInput index={index} control={control} />}
19
+ renderElement={(index) => (
20
+ <Input
21
+ name={`authors.${index}.value`}
22
+ preserveHintSpace={false}
23
+ control={control}
24
+ defaultValue={defaultValue[index]?.value}
25
+ />
26
+ )}
15
27
  addButton={{
16
28
  label: "ln.admin.add-author",
17
29
  onClick: (append, index) =>
@@ -20,24 +32,3 @@ export default function Authors({ control }: { control: Control<MediaItem> }) {
20
32
  />
21
33
  )
22
34
  }
23
-
24
- function AuthorInput({
25
- index,
26
- control,
27
- }: {
28
- index: number
29
- control: Control<MediaItem>
30
- }) {
31
- const name = `authors.${index}.value` as const
32
- const errorMessage = useFieldError({ name, control })
33
- return (
34
- <>
35
- <input
36
- className={`dy-input dy-input-bordered shadow-inner ${errorMessage ? "dy-input-error" : ""}`}
37
- aria-invalid={!!errorMessage}
38
- {...control.register(name)}
39
- />
40
- <ErrorMessage message={errorMessage} />
41
- </>
42
- )
43
- }
@@ -1,15 +1,16 @@
1
1
  import { type Control } from "react-hook-form"
2
2
 
3
- import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
4
3
  import DynamicArray from "../../../components/form/DynamicArray"
5
- import { useFieldError } from "../../../components/form/hooks/use-field-error"
4
+ import Select from "../../../components/form/Select"
6
5
  import type { MediaItem } from "../../../types/media-item"
7
6
 
8
7
  export default function Categories({
9
8
  control,
10
9
  categories,
10
+ defaultValue,
11
11
  }: {
12
12
  control: Control<MediaItem>
13
+ defaultValue: MediaItem["categories"]
13
14
  categories: { id: string; labelText: string }[]
14
15
  }) {
15
16
  return (
@@ -18,10 +19,12 @@ export default function Categories({
18
19
  name="categories"
19
20
  label="ln.categories"
20
21
  renderElement={(index) => (
21
- <CategorySelect
22
- categories={categories}
22
+ <Select
23
+ options={categories}
23
24
  control={control}
24
- index={index}
25
+ name={`categories.${index}.value`}
26
+ defaultValue={defaultValue[index]?.value}
27
+ preserveHintSpace={false}
25
28
  />
26
29
  )}
27
30
  addButton={{
@@ -32,33 +35,3 @@ export default function Categories({
32
35
  />
33
36
  )
34
37
  }
35
-
36
- function CategorySelect({
37
- control,
38
- categories,
39
- index,
40
- }: {
41
- control: Control<MediaItem>
42
- categories: { id: string; labelText: string }[]
43
- index: number
44
- }) {
45
- const name = `categories.${index}.value` as const
46
- const errorMessage = useFieldError({ name, control })
47
- return (
48
- <>
49
- <select
50
- {...control.register(name)}
51
- id={name}
52
- aria-invalid={!!errorMessage}
53
- className={`dy-select dy-select-bordered text-base shadow-sm ${errorMessage ? "dy-select-error" : ""}`}
54
- >
55
- {categories.map(({ id, labelText }) => (
56
- <option key={id} value={id}>
57
- {labelText}
58
- </option>
59
- ))}
60
- </select>
61
- <ErrorMessage message={errorMessage} />
62
- </>
63
- )
64
- }