Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • fcos-suite/fcos-suite-map
1 result
Show changes
Commits on Source (38)
Showing
with 14224 additions and 4013 deletions
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['react', '@typescript-eslint'],
rules: {
'react/prop-types': 0,
},
};
...@@ -28,4 +28,4 @@ lftp deploy: ...@@ -28,4 +28,4 @@ lftp deploy:
only: only:
- main - main
script: script:
- lftp -e "set net:timeout 5; set net:max-retries 3; set net:reconnect-interval-base 5; open sftp://$SFTP_HOST; user $SFTP_USER $SFTP_PASSWORD; mirror -X .* -X .*/ --reverse --verbose build/ live/; bye" - lftp -e "set net:timeout 5; set net:max-retries 3; set net:reconnect-interval-base 5; open sftp://$SFTP_HOST; user $SFTP_USER $SFTP_PASSWORD; mirror -X .* -X .*/ --reverse --verbose build/ /httpdocs/map; bye"
\ No newline at end of file
14
\ No newline at end of file
overwrite: true
schema: 'http://localhost:8000/graphql'
documents: 'src/graphql/**/*.ts'
generates:
src/generated/graphql.ts:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-graphql-request'
config:
useTypeImports: true
[[headers]]
for = "/*"
[headers.values]
Access-Control-Allow-Origin = "*"
\ No newline at end of file
This diff is collapsed.
{ {
"scripts": { "scripts": {
"start": "snowpack dev", "start": "snowpack dev",
"build": "snowpack build", "build": "snowpack build && npm run copy:htaccess",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"" "prettier:lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"",
"eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"copy:htaccess": "cp public/.htaccess build/",
"generate": "graphql-codegen --config codegen.yml"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/forms": "^0.3.2", "@tailwindcss/forms": "^0.3.2",
...@@ -13,28 +16,38 @@ ...@@ -13,28 +16,38 @@
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"react": "^17.0.0", "react": "^17.0.0",
"react-dom": "^17.0.0", "react-dom": "^17.0.0",
"react-leaflet": "^3.1.0", "react-leaflet": "^3.2.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"swr": "^0.5.5", "react-select": "4.0.2",
"swr": "^0.5.6",
"tailwindcss": "^2.0.3", "tailwindcss": "^2.0.3",
"zustand": "^3.4.1" "zustand": "^3.5.1"
}, },
"devDependencies": { "devDependencies": {
"@jadex/snowpack-plugin-tailwindcss-jit": "^0.2.0", "@graphql-codegen/cli": "1.21.4",
"@graphql-codegen/typescript": "1.22.0",
"@graphql-codegen/typescript-graphql-request": "^3.2.0",
"@graphql-codegen/typescript-operations": "1.17.16",
"@snowpack/plugin-dotenv": "^2.1.0", "@snowpack/plugin-dotenv": "^2.1.0",
"@snowpack/plugin-postcss": "^1.1.0", "@snowpack/plugin-postcss": "^1.3.0",
"@snowpack/plugin-react-refresh": "^2.4.0", "@snowpack/plugin-react-refresh": "^2.4.0",
"@snowpack/plugin-typescript": "^1.2.0", "@snowpack/plugin-typescript": "^1.2.0",
"@types/leaflet": "^1.7.0", "@types/leaflet": "^1.7.0",
"@types/react": "^17.0.0", "@types/react": "^17.0.6",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.5",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"@types/react-select": "^4.0.15",
"@types/snowpack-env": "^2.3.2", "@types/snowpack-env": "^2.3.2",
"@typescript-eslint/eslint-plugin": "^4.24.0",
"@typescript-eslint/parser": "^4.24.0",
"autoprefixer": "^10.2.4", "autoprefixer": "^10.2.4",
"postcss": "^8.2.6", "eslint": "^7.26.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-react": "^7.23.2",
"postcss": "^8.3.0",
"postcss-cli": "^8.3.1", "postcss-cli": "^8.3.1",
"prettier": "^2.0.5", "prettier": "^2.3.0",
"snowpack": "^3.0.1", "snowpack": "^3.5.0",
"typescript": "^4.0.0" "typescript": "^4.0.0"
} }
} }
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteCond %{HTTP_HOST} ^(?:www\.)?(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [L,NE,R=301]
RewriteCond %{REQUEST_FILENAME} -s [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L]
RewriteRule ^(.*) /index.html [NC,L]
</IfModule>
\ No newline at end of file
...@@ -6,8 +6,8 @@ ...@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fab City Hamburg Map</title> <title>Fab City Hamburg Map</title>
<!-- TODO: This should be imported from the leaflet npm package, not manually from the public folder. See https://github.com/snowpackjs/snowpack/discussions/1716 --> <!-- TODO: This should be imported from the leaflet npm package, not manually from the public folder. See https://github.com/snowpackjs/snowpack/discussions/1716 -->
<link rel="stylesheet" href="leaflet.css" /> <link rel="stylesheet" href="/leaflet.css" />
<script src="leaflet.js"></script> <script src="/leaflet.js"></script>
</head> </head>
<body class="h-full w-full"> <body class="h-full w-full">
<div id="root" class="h-full w-full"></div> <div id="root" class="h-full w-full"></div>
......
...@@ -5,12 +5,10 @@ module.exports = { ...@@ -5,12 +5,10 @@ module.exports = {
src: { url: '/dist' }, src: { url: '/dist' },
}, },
plugins: [ plugins: [
'@snowpack/plugin-react-refresh',
'@snowpack/plugin-dotenv', '@snowpack/plugin-dotenv',
'@snowpack/plugin-react-refresh',
'@snowpack/plugin-typescript', '@snowpack/plugin-typescript',
'@snowpack/plugin-postcss', '@snowpack/plugin-postcss',
'@snowpack/plugin-dotenv',
'@jadex/snowpack-plugin-tailwindcss-jit',
], ],
routes: [ routes: [
/* Enable an SPA Fallback in development: */ /* Enable an SPA Fallback in development: */
...@@ -24,7 +22,7 @@ module.exports = { ...@@ -24,7 +22,7 @@ module.exports = {
/* ... */ /* ... */
}, },
devOptions: { devOptions: {
/* ... */ tailwindConfig: './tailwind.config.js',
}, },
buildOptions: { buildOptions: {
/* ... */ /* ... */
......
...@@ -2,19 +2,26 @@ import React from 'react'; ...@@ -2,19 +2,26 @@ import React from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { useStore } from '../hooks'; import { useStore } from '../hooks';
import ErrorModal from './ErrorModal'; import ErrorModal from './ErrorModal';
import Notification from './Notification';
import Map from './Map/Map'; import Map from './Map/Map';
import SidebarCreateView from './Sidebar/SidebarCreateView'; import SidebarCreateView from './Sidebar/SidebarCreateView';
import SidebarListView from './Sidebar/SidebarListView'; import SidebarListView from './Sidebar/SidebarListView';
import SidebarSingleView from './Sidebar/SidebarSingleView'; import SidebarSingleView from './Sidebar/SidebarSingleView';
import PoiLoader from './PoiLoader';
const App = () => { const App: React.FC = () => {
const selectedPoi = useStore((state) => state.selectedPoi); const selectedPoi = useStore((state) => state.selectedPoi);
return ( return (
<> <>
<ErrorModal /> <ErrorModal />
<Notification />
<div className={'flex md:flex-row-reverse flex-col h-full'}> <div className={'flex md:flex-row-reverse flex-col h-full'}>
<Route path="/add" children={({ match }) => <Map hideAllPois={!!match} />} /> <Route path="/add">{({ match }) => <Map createMode={!!match} />}</Route>
<Route path="/poi/:poiId">{({ match }) => <PoiLoader poiId={match?.params?.poiId as string} />}</Route>
<Route exact path="/">
<PoiLoader poiId={null} />
</Route>
<Switch> <Switch>
<Route exact path="/add"> <Route exact path="/add">
<SidebarCreateView /> <SidebarCreateView />
......
import React from 'react';
import type { Theme } from 'react-select';
import type { NamedProps, StylesConfig } from 'react-select';
import Creatable from 'react-select/creatable';
const CreatableSelect = <OptionType, isMulti extends boolean>(props: NamedProps<OptionType, isMulti>): JSX.Element => {
const customStyles: StylesConfig<OptionType, isMulti> = {
control: (provided) => ({
...provided,
border: '0',
borderRadius: '0.5em',
boxShadow: 'none',
}),
multiValue: (provided) => ({ ...provided, borderRadius: '999px', padding: '0 3px' }),
multiValueRemove: (provided) => ({
...provided,
color: 'hsl(0, 0%, 50%)',
'&:hover': { backgroundColor: 'initial', color: 'black' },
}),
};
return (
<div>
<Creatable
theme={(theme): Theme => ({
...theme,
// @ts-expect-error: ThemeConfig type from definitely-typed is not complete
borderRadius: '0.5em',
colors: {
...theme.colors,
primary25: '#C7D2FE',
},
})}
styles={customStyles}
name="pois"
className="hover:border-opacity-40 rounded-lg w-full border-2 border-black border-opacity-20 focus-within:border-indigo-300 focus-within:ring focus-within:ring-indigo-200 focus-within:ring-opacity-50 mt-1"
{...props}
/>
</div>
);
};
export default CreatableSelect;
...@@ -3,9 +3,7 @@ import React from 'react'; ...@@ -3,9 +3,7 @@ import React from 'react';
import { useStore } from '../hooks'; import { useStore } from '../hooks';
import Modal from './Modal'; import Modal from './Modal';
interface Props {} const ErrorModal: React.FC = () => {
const ErrorModal: React.FC<Props> = () => {
const error = useStore((state) => state.error); const error = useStore((state) => state.error);
const icons: { [index: string]: JSX.Element } = { const icons: { [index: string]: JSX.Element } = {
alert: <AlertIcon className="h-6 w-6 text-red-600" />, alert: <AlertIcon className="h-6 w-6 text-red-600" />,
......
import React, { useEffect, useRef, useState } from 'react';
import type { PointOfInterestFormData, Tag } from '../../types/PointOfInterest';
import fetcher from '../../util/fetcher';
import { validateFile } from '../../util/file';
import TextInput from './TextInput';
import FileInput from './FileInput';
import TextAreaInput from './TextAreaInput';
import { usePoiData, useStore } from '../../hooks';
import { useHistory } from 'react-router-dom';
import CoordinateInput from './CoordinateInput';
import TagInput from './TagInput';
import { removeDuplicateObjects } from '../../util/array';
import { createPoi, createTags } from '../../graphql/mutations';
import Spinner from '../Spinner';
import type { CreatePoiMutationMutationVariables, Mutation } from '../../generated/graphql';
import SelectInput from './SelectInput';
type StringSelectOption = { label: string; value: string };
const AddPoiForm: React.FC = () => {
const [formData, setFormData] = useState<PointOfInterestFormData>({
lat: 0,
lng: 0,
email: '',
name: '',
address: '',
description: '',
website: '',
category: '',
relationStatus: '',
image: null,
tags: [],
});
const [isValid, setIsValid] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const draftPoi = useStore((state) => state.draftPoi);
const formRef = useRef<HTMLFormElement>(null);
const history = useHistory();
const { data } = usePoiData();
const [tagOptions, setTagOptions] = useState<Tag[]>([]);
const [categoryOptions, setCatgeoryOptions] = useState<StringSelectOption[]>([]);
const [relationStatusOptions, setRelationStatusOptions] = useState<StringSelectOption[]>([]);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const setNotification = useStore((state) => state.setNotification);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e?.target?.files?.length) {
const isFileValid = validateFile(e?.target?.files[0]);
if (isFileValid) {
setFormData({ ...formData, [e.target.name]: e?.target?.files[0] });
}
}
};
const handleSubmit = async (e: React.SyntheticEvent) => {
e.preventDefault();
formRef.current?.reportValidity();
setIsLoading(true);
try {
const newTags: Tag[] = [];
let newTagIdsResponse: string[] = [];
const oldTags: Tag[] = [];
selectedTags.forEach((tag) => (tag.id === 'draft' ? newTags.push(tag) : oldTags.push(tag)));
if (newTags.length) {
const tagsRes: Mutation = await fetcher(createTags, {
tags: newTags.map(({ displayName, color }) => ({
displayName,
color,
})),
});
if (tagsRes.createTags && tagsRes.createTags.length > 0) {
newTagIdsResponse = tagsRes.createTags
.filter((newTagResponse) => newTagResponse?.id)
.map((newTagResponse) => {
return (newTagResponse as Tag).id;
});
}
}
const finalData: CreatePoiMutationMutationVariables = {
...formData,
tagIds: [...newTagIdsResponse, ...oldTags.map((oldTag) => oldTag.id)],
};
const res: Mutation = await fetcher(createPoi, finalData);
if (res.createPoi) {
setNotification({
title: 'Ort hinzugefügt',
text: 'Ort wurde erfolgreich hinzugefügt. Bitte überprüfe deine E-Mails und klicke dort auf den Link um deine Mail-Adresse zu verifizieren.',
type: 'success',
});
history.push('/');
} else {
setIsLoading(false);
throw new Error();
}
} catch (error) {
console.error(error);
setIsLoading(false);
setNotification({
title: 'Fehler beim Hinzufügen',
text: 'Ort konnte nicht hinzgefügt werden. Bitte später erneut probieren.',
type: 'alert',
});
}
};
// Aggregate all tags from all POIs and remove duplicates
useEffect(() => {
if (data) {
const tagsWithDuplicates = data.flatMap((poi) => {
return poi.tags;
});
const tags = removeDuplicateObjects(tagsWithDuplicates, 'id');
setTagOptions(tags);
const relationStatuses = removeDuplicateObjects(data, 'relationStatus')
.filter((poi) => !!poi.relationStatus)
.map((poi) => ({ label: poi.relationStatus, value: poi.relationStatus }));
setRelationStatusOptions(relationStatuses);
const categories = removeDuplicateObjects(data, 'category')
.filter((poi) => !!poi.category)
.map((poi) => ({ label: poi.category, value: poi.category }));
setCatgeoryOptions(categories);
}
}, [data]);
useEffect(() => {
// Validation of native HTML input constraints
const htmlValid = formRef.current?.checkValidity();
if (draftPoi && htmlValid) {
if (!isValid) setIsValid(true);
} else {
if (isValid) setIsValid(false);
}
}, [formData, draftPoi]);
useEffect(() => {
// Set lat/lng in Form when PIN is dropped on map
if (draftPoi) setFormData({ ...formData, lat: draftPoi[0], lng: draftPoi[1] });
}, [draftPoi]);
return (
<form className="flex-1 flex flex-col justify-between" onSubmit={handleSubmit} ref={formRef}>
<div className="flex flex-col">
<CoordinateInput
label={'Koordinaten'}
text="Bitte Pin auf Karte setzen."
value={[formData.lat, formData.lng]}
required
/>
<TagInput label={'Tags'} tags={selectedTags} options={tagOptions} onTagsChange={setSelectedTags} />
<TextInput label={'Name des Orts'} name={'name'} value={formData.name} onChange={handleInputChange} required />
<SelectInput
label={'Kategorie'}
required
placeholder={'Auswählen...'}
name={'category'}
options={categoryOptions}
onChange={(selectedOption) =>
setFormData((prev) => ({
...prev,
category: selectedOption ? (selectedOption as StringSelectOption).value : '',
}))
}
/>
<SelectInput
label={'Verhältnis zum Fab City Hamburg e.V.'}
required
name={'relationStatus'}
placeholder={'Auswählen...'}
options={relationStatusOptions}
onChange={(selectedOption) =>
setFormData((prev) => ({
...prev,
relationStatus: selectedOption ? (selectedOption as StringSelectOption).value : '',
}))
}
/>
<TextInput
label={'Anschrift'}
name={'address'}
value={formData.address}
onChange={handleInputChange}
required
/>
<TextInput
label={'E-Mail (wird nicht veröffentlicht)'}
name={'email'}
value={formData.email}
onChange={(event) => {
// event.target.setCustomValidity('Custom message');
handleInputChange(event);
}}
type="email"
required
/>
<TextInput
type="url"
label={'Webseite'}
name={'website'}
value={formData.website}
onChange={handleInputChange}
required
/>
<TextAreaInput
label={'Beschreibung'}
name={'description'}
value={formData.description}
onChange={handleInputChange}
required
/>
<FileInput value={formData.image} onChange={handleFileChange} name={'image'} label={'Bild'} required />
</div>
<button disabled={isLoading} type="submit" className="form-button">
{isLoading && <Spinner />}
Hinzufügen
</button>
</form>
);
};
export default AddPoiForm;
import React from 'react';
import { CheckCircleOutline as CheckIcon } from 'heroicons-react';
interface Props {
label: string;
value: [number | '', number | ''];
required?: boolean;
text?: string;
}
const CoordinateInput: React.FC<Props> = ({ label, text, value, required }) => {
return (
<>
{!!label && (
<span className="form-label">
{label}
{required && `*`}
</span>
)}
<label className="flex mt-1 mb-4 p-2 items-center w-full rounded-lg border-2 border-black border-opacity-20 hover:border-opacity-40 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<CheckIcon className={`${value.filter(Boolean).length ? 'text-indigo-500' : 'text-gray-400'} mr-2`} />
<input readOnly type="text" name={'lat'} value={value[0]} className="hidden" required={required}></input>
<input readOnly type="text" name={'lng'} value={value[1]} className="hidden" required={required}></input>
{!!text && <span className="form-label">{text}</span>}
</label>
</>
);
};
export default CoordinateInput;
import React from 'react';
import { PaperClipOutline as PaperClipIcon, UploadOutline as UploadIcon } from 'heroicons-react';
interface Props {
label: string;
value: File | null;
name: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required: boolean;
}
const FileInput: React.FC<Props> = ({ name, label, value, onChange, ...inputProps }) => {
return (
<>
<span className="form-label">
{label}
{inputProps?.required && `*`}
</span>
<label
className={`mt-1 flex items-center rounded-lg border-2 border-black mb-6 w-full p-2 text-center focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 cursor-pointer${
value
? ' bg-white text-black border-opacity-20 hover:border-opacity-40 truncate text-sm text-opacity-40'
: ' bg-indigo-500 text-white border-opacity-0 hover:bg-indigo-600 text-md'
}`}
>
{value ? (
<>
<PaperClipIcon size={18} className="flex-shrink-0 mr-2" />
{value.name}
</>
) : (
<>
<UploadIcon size={18} className="flex-shrink-0 mr-2" />
{'Datei auswählen...'}
</>
)}
<input
className="hidden"
type="file"
name={name}
accept="image/png, image/jpeg"
onChange={onChange}
{...inputProps}
/>
</label>
</>
);
};
export default FileInput;
import React from 'react';
import type { NamedProps } from 'react-select';
import CreatableSelect from '../CreatableSelect';
interface Props<OptionType, isMulti extends boolean> extends NamedProps<OptionType, isMulti> {
label: string;
name: string;
required?: boolean;
}
const SelectInput = <OptionType, isMulti extends boolean>({
name,
label,
value,
onChange,
...inputProps
}: Props<OptionType, isMulti>): JSX.Element => {
return (
<label className="block mb-4">
{!!label && (
<span className="form-label">
{label}
{inputProps?.required && `*`}
</span>
)}
<CreatableSelect
name={name}
value={value}
className="p-0 form-input form-input-custom"
onChange={onChange}
{...inputProps}
/>
</label>
);
};
export default SelectInput;
import React, { useEffect, useMemo, useState } from 'react';
import type { Tag as TagType } from 'src/types/PointOfInterest';
import { generateRandomHslColor } from '../../util/color';
import Tag from '../Tag';
interface Props {
label?: string;
tags: TagType[];
options: TagType[];
onTagsChange: (tags: TagType[]) => void;
required?: boolean;
}
const TagInput: React.FC<Props> = ({ label, tags, options, onTagsChange, required }: Props) => {
// All options
const [tagOptions, setTagOptions] = useState<TagType[]>([]);
// The text input
const [draftEntry, setDraftEntry] = useState('');
const [showOptions, setShowOptions] = useState(false);
// Only show options that start with current draftEntry value
const filteredTagOptions = useMemo(
() => tagOptions.filter((tagOption) => tagOption.displayName.toLowerCase().startsWith(draftEntry.toLowerCase())),
[tagOptions, draftEntry],
);
const handleKeyboardInput = (e: React.KeyboardEvent) => {
if (draftEntry === '') {
if (e.nativeEvent.key === 'Backspace') {
const tagsClone = JSON.parse(JSON.stringify(tags));
const removedTag = tagsClone.pop();
if (removedTag) {
onTagsChange(tagsClone);
}
}
} else {
if (e.nativeEvent.key === 'Enter') {
e.preventDefault();
// Check if a Tag with that name already exists
const allTagNames = tags.map((tag) => tag.displayName.toLowerCase());
const isDuplicate = allTagNames.includes(draftEntry.toLowerCase());
if (!isDuplicate) {
onTagsChange([...tags, { displayName: draftEntry, color: generateRandomHslColor(60, 80), id: 'draft' }]);
}
setDraftEntry('');
}
}
};
const handleClickDeleteTag = (tag: TagType) => {
onTagsChange(tags.filter((filterTag) => tag.displayName.toLowerCase() !== filterTag.displayName.toLowerCase()));
};
const handleSelectTag = (tag: TagType) => {
onTagsChange([...tags, tag]);
};
useEffect(() => {
setTagOptions(
options.filter(
(tagOption) => !tags.find((tag) => tag.displayName.toLowerCase() === tagOption.displayName.toLowerCase()),
),
);
}, [tags, options]);
return (
<label className="relative block mb-4">
{!!label && (
<span className="form-label">
{label}
{required && `*`}
</span>
)}
<div
tabIndex={0}
className={`${
showOptions && filteredTagOptions.length ? 'rounded-t-lg hover:border-opacity-40 ' : 'rounded-lg '
}flex w-full border-2 border-black border-opacity-20 focus-within:border-indigo-300 focus-within:ring focus-within:ring-indigo-200 focus-within:ring-opacity-50 input-chevron mt-1`}
>
<div className="flex flex-wrap items-center flex-1 p-2">
{tags.map((selectedTag) => (
<Tag
key={`selected${selectedTag.color}`}
onClickDelete={() => handleClickDeleteTag(selectedTag)}
color={selectedTag.color}
>
{selectedTag.displayName}
</Tag>
))}
<input
onKeyDown={handleKeyboardInput}
onChange={(e) => {
const newValue = e.target.value;
setDraftEntry(newValue);
}}
onFocus={() => setShowOptions(true)}
onBlur={() => {
setShowOptions(false);
}}
type={'text'}
value={draftEntry}
className={`form-input w-16 block p-0 flex-1 rounded-lg border-0 focus:outline-none focus:ring-0 ${
tags.length ? '' : 'px-3'
}`}
/>
</div>
</div>
{/* Options Dropdown */}
{filteredTagOptions.length > 0 && (
<ul
className={`${
showOptions && filteredTagOptions.length ? 'block ' : 'hidden '
}rounded-b-lg z-10 absolute w-full bg-white border-2 border-t-0 shadow-md`}
>
{filteredTagOptions.map((option) => {
return (
<li
key={`${option.id}`}
onMouseDown={() => {
handleSelectTag(option);
}}
className="w-full p-1 hover:bg-gray-100"
>
<Tag color={option.color}>{option.displayName}</Tag>
</li>
);
})}
</ul>
)}
</label>
);
};
export default TagInput;
import React from 'react';
interface Props {
label: string;
value: string;
name: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
required?: boolean;
}
const TextAreaInput: React.FC<Props> = ({ name, label, value, onChange, ...textAreaProps }) => {
return (
<label className="block mb-4">
{!!label && (
<span className="form-label">
{label}
{textAreaProps?.required && `*`}
</span>
)}
<textarea
name={name}
value={value}
className="form-textarea form-input-custom"
rows={3}
onChange={onChange}
{...textAreaProps}
></textarea>
</label>
);
};
export default TextAreaInput;
import React from 'react';
interface Props {
label: string;
value: string;
name: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
type?: string;
}
const TextInput: React.FC<Props> = ({ name, label, value, onChange, type = 'text', ...inputProps }) => {
return (
<label className="block mb-4">
{!!label && (
<span className="form-label">
{label}
{inputProps?.required && `*`}
</span>
)}
<input
type={type}
name={name}
value={value}
className="form-input form-input-custom"
onChange={onChange}
{...inputProps}
/>
</label>
);
};
export default TextInput;