浏览代码

working on forms

Tomi Cvetic 5 年之前
父节点
当前提交
63f1246595

+ 1 - 1
backend/schema.graphql

@@ -62,7 +62,7 @@ type Mutation {
     location: String!
     attendance: Int!
     published: Boolean!
-    blocks: [BlockCreateInput!]!
+    blocks: BlockCreateManyInput
   ): Training!
   createTrainingType(name: String!, description: String!): TrainingType!
   createBlock(

+ 9 - 1
frontend/global.d.ts

@@ -1,2 +1,10 @@
 type Dict = { [key: string]: any }
-type NestedEvent = { target: { type: 'custom'; name: string; value: any } }
+type CustomChangeEvent = {
+  target: { type: 'custom'; name: string; value: any }
+}
+type GenericEventHandler = (
+  event:
+    | ChangeEvent<HTMLSelectElement>
+    | ChangeEvent<HTMLInputElement>
+    | CustomChangeEvent
+) => void

+ 19 - 8
frontend/src/form/components/Checkbox.tsx

@@ -1,15 +1,26 @@
-import { useField } from 'formik'
-import { InputProps } from '../types'
+import { DetailedHTMLProps, InputHTMLAttributes } from 'react'
 
-const Checkbox = ({ children, ...props }: any) => {
-  const [field, meta] = useField({ ...props, type: 'checkbox' })
+type ICheckbox = {
+  value: boolean
+  label?: string
+} & Omit<
+  DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
+  'value'
+>
+
+const Checkbox = ({ name, label, id, value, type, ...props }: ICheckbox) => {
   return (
     <>
-      <label className='checkbox'>
-        <input type='checkbox' {...field} {...props} />
-      </label>
+      {label && <label htmlFor={id || name}>{label}</label>}
+      <input
+        id={id || label}
+        name={name}
+        checked={value}
+        type='checkbox'
+        {...props}
+      />
     </>
   )
 }
 
-export default Checkbox
+export default Checkbox

+ 80 - 0
frontend/src/form/components/DateTimeInput.tsx

@@ -0,0 +1,80 @@
+import { format, parse, parseISO } from 'date-fns'
+import {
+  useState,
+  ChangeEvent,
+  DetailedHTMLProps,
+  InputHTMLAttributes
+} from 'react'
+
+type IDateTime = {
+  onChange: GenericEventHandler
+  label?: string
+  value: string
+} & Omit<
+  DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
+  'onChange' | 'value'
+>
+
+const DateTimeInput = ({
+  name,
+  label,
+  id,
+  type,
+  value,
+  onChange,
+  ...props
+}: IDateTime) => {
+  const timeFormat = 'HH:mm'
+  const dateFormat = 'yyyy-MM-dd'
+
+  const [state, setState] = useState({
+    DTILOCALSTATEdate: format(parseISO(value), dateFormat),
+    DTILOCALSTATEtime: format(parseISO(value), timeFormat)
+  })
+
+  function handleChange(event: ChangeEvent<HTMLInputElement>) {
+    setState({ ...state, [event.target.name]: event.target.value })
+  }
+
+  function handleBlur(event: ChangeEvent<HTMLInputElement>) {
+    const timeISO = parse(
+      `${state.DTILOCALSTATEdate} ${state.DTILOCALSTATEtime}`,
+      `${dateFormat} ${timeFormat}`,
+      new Date(0)
+    )
+    const returnEvent = {
+      target: {
+        name,
+        type: 'custom',
+        value: timeISO.toISOString()
+      }
+    }
+    onChange(returnEvent)
+  }
+
+  return (
+    <>
+      <input
+        type='date'
+        id='DTILOCALSTATEdate'
+        name='DTILOCALSTATEdate'
+        {...props}
+        value={state.DTILOCALSTATEdate}
+        onChange={handleChange}
+        onBlur={handleBlur}
+      />
+      <input
+        type='time'
+        id='DTILOCALSTATEtime'
+        name='DTILOCALSTATEtime'
+        {...props}
+        step={60}
+        value={state.DTILOCALSTATEtime}
+        onChange={handleChange}
+        onBlur={handleBlur}
+      />
+    </>
+  )
+}
+
+export default DateTimeInput

+ 5 - 11
frontend/src/form/components/Select.tsx

@@ -1,17 +1,11 @@
-import { useField } from 'formik'
-import { InputProps } from '../types'
-
-const Select = ({ label, ...props }: any) => {
-  const [field, meta] = useField(props)
+const Select = ({ name, id, label, touched, error, ...props }: any) => {
   return (
     <>
-      <label htmlFor={props.id || props.name}>{label}</label>
-      <select {...field} {...props} />
-      {(meta.touched && meta.error) && (
-        <div className='error'>{meta.error}</div>
-      )}
+      {label && <label htmlFor={props.id || props.name}>{label}</label>}
+      <select name={name} id={id || name} {...props} />
+      {touched && error && <div className='error'>{error}</div>}
     </>
   )
 }
 
-export default Select
+export default Select

+ 37 - 14
frontend/src/form/components/TextInput.tsx

@@ -1,23 +1,46 @@
-import { useEffect } from 'react'
-import { useField } from 'formik'
-import { InputProps } from '../types'
+import { DetailedHTMLProps, InputHTMLAttributes, ChangeEvent } from 'react'
 
-const TextInput = ({ label, setState, ...props }: any) => {
-  const [field, meta] = useField(props)
+type ITextInput = {
+  onChange: GenericEventHandler
+  label?: string
+} & Omit<
+  DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
+  'onChange'
+>
 
-  useEffect(() => {
-  }, [meta])
+const TextInput = ({
+  name,
+  label,
+  id,
+  type,
+  onChange,
+  ...props
+}: ITextInput) => {
+  function handleChange(event: ChangeEvent<HTMLInputElement>) {
+    if (event.target.type === 'number') {
+      const newValue = {
+        target: {
+          type: 'custom',
+          value: parseInt(event.target.value),
+          name: event.target.name
+        }
+      }
+      onChange(newValue)
+    } else return onChange(event)
+  }
 
-  console.log('TextInput', meta)
   return (
     <>
-      {label && <label htmlFor={props.id || props.name}>{label}</label>}
-      <input {...field} {...props} />
-      {(meta.touched && meta.error) && (
-        <div className='error'>{meta.error}</div>
-      )}
+      {label && <label htmlFor={id || name}>{label}</label>}
+      <input
+        id={id || name}
+        name={name}
+        type={type}
+        onChange={handleChange}
+        {...props}
+      />
     </>
   )
 }
 
-export default TextInput
+export default TextInput

+ 3 - 63
frontend/src/form/hooks/useForm.tsx

@@ -1,5 +1,4 @@
 import { useState, ChangeEvent } from 'react'
-import { format, parse, parseISO } from 'date-fns'
 import { set, cloneDeep } from 'lodash'
 
 export function useForm<T extends Dict>(initialValues: T) {
@@ -9,11 +8,11 @@ export function useForm<T extends Dict>(initialValues: T) {
     .filter(([key, value]) => value !== values[key])
     .map(([key]) => key)
 
-  function handleChange(
+  function onChange(
     event:
       | ChangeEvent<HTMLInputElement>
       | ChangeEvent<HTMLSelectElement>
-      | NestedEvent
+      | CustomChangeEvent
   ) {
     const { type, name, value } = event.target
     const newValue =
@@ -30,64 +29,5 @@ export function useForm<T extends Dict>(initialValues: T) {
     }
   }
 
-  return { touched, values, handleChange }
-}
-
-export const DateTimeInput = ({ name, id, value, onChange, ...props }: any) => {
-  const timeFormat = 'HH:mm'
-  const dateFormat = 'yyyy-MM-dd'
-  const date = parseISO(value)
-  console.log('render', date)
-  const [state, setState] = useState({
-    date: format(parseISO(value), dateFormat),
-    time: format(parseISO(value), timeFormat)
-  })
-
-  function handleChange(event: ChangeEvent<HTMLInputElement>) {
-    setState({ ...state, [event.target.name]: event.target.value })
-  }
-
-  function handleBlur(event: ChangeEvent<HTMLInputElement>) {
-    const timeISO = parse(
-      `${state.date} ${state.time}`,
-      `${dateFormat} ${timeFormat}`,
-      new Date(0)
-    )
-    const returnEvent = {
-      ...event,
-      target: {
-        ...event.target,
-        name,
-        type: 'text',
-        value: timeISO.toISOString()
-      }
-    }
-    console.log('blur', state, timeISO, returnEvent)
-    onChange(returnEvent)
-  }
-
-  return (
-    <>
-      <input
-        type='date'
-        id='date'
-        name='date'
-        {...props}
-        step={60}
-        value={state.date}
-        onChange={handleChange}
-        onBlur={handleBlur}
-      />
-      <input
-        type='time'
-        id='time'
-        name='time'
-        {...props}
-        step={60}
-        value={state.time}
-        onChange={handleChange}
-        onBlur={handleBlur}
-      />
-    </>
-  )
+  return { values, touched, onChange }
 }

+ 3 - 6
frontend/src/form/index.ts

@@ -1,9 +1,6 @@
 import TextInput from './components/TextInput'
 import Checkbox from './components/Checkbox'
-import { useFormHandler } from './useFormHandler'
+import DateTimeInput from './components/DateTimeInput'
+import { useForm } from './hooks/useForm'
 
-export {
-  TextInput,
-  Checkbox,
-  useFormHandler
-}
+export { DateTimeInput, TextInput, Checkbox, useForm }

+ 3 - 3
frontend/src/gql/index.tsx

@@ -888,7 +888,7 @@ export type MutationCreateTrainingArgs = {
   location: Scalars['String'],
   attendance: Scalars['Int'],
   published: Scalars['Boolean'],
-  blocks: Array<BlockCreateInput>
+  blocks?: Maybe<BlockCreateManyInput>
 };
 
 
@@ -2098,7 +2098,7 @@ export type CreateTrainingMutationVariables = {
   location: Scalars['String'],
   attendance: Scalars['Int'],
   published: Scalars['Boolean'],
-  blocks: Array<BlockCreateInput>
+  blocks?: Maybe<BlockCreateManyInput>
 };
 
 
@@ -2329,7 +2329,7 @@ export type FormatsQueryHookResult = ReturnType<typeof useFormatsQuery>;
 export type FormatsLazyQueryHookResult = ReturnType<typeof useFormatsLazyQuery>;
 export type FormatsQueryResult = ApolloReactCommon.QueryResult<FormatsQuery, FormatsQueryVariables>;
 export const CreateTrainingDocument = gql`
-    mutation createTraining($title: String!, $type: TrainingTypeCreateOneInput!, $trainingDate: DateTime!, $location: String!, $attendance: Int!, $published: Boolean!, $blocks: [BlockCreateInput!]!) {
+    mutation createTraining($title: String!, $type: TrainingTypeCreateOneInput!, $trainingDate: DateTime!, $location: String!, $attendance: Int!, $published: Boolean!, $blocks: BlockCreateManyInput) {
   createTraining(title: $title, type: $type, trainingDate: $trainingDate, location: $location, attendance: $attendance, published: $published, blocks: $blocks) {
     id
   }

+ 63 - 0
frontend/src/training/components/BlockFormInputs.tsx

@@ -0,0 +1,63 @@
+import { BlockCreateInput } from '../../gql'
+import FormatSelector from './FormatSelector'
+import { TextInput } from '../../form'
+
+interface IBlockFormInputs {
+  onChange: GenericEventHandler
+  value: BlockCreateInput
+  name: string
+}
+
+const BlockFormInputs = ({ onChange, value, name }: IBlockFormInputs) => {
+  return (
+    <fieldset>
+      <TextInput
+        name={`${name}.sequence`}
+        label='Sequence'
+        value={value.sequence}
+        type='number'
+        onChange={onChange}
+      />
+      <TextInput
+        name={`${name}.title`}
+        label='Title'
+        value={value.title}
+        onChange={onChange}
+      />
+      <FormatSelector
+        name={`${name}.format`}
+        value={value.format}
+        onChange={onChange}
+      />
+      <TextInput
+        name={`${name}.description`}
+        label='Description'
+        value={value.description || undefined}
+        onChange={onChange}
+      />
+      <TextInput
+        name={`${name}.duration`}
+        label='Duration'
+        value={value.duration || undefined}
+        type='number'
+        onChange={onChange}
+      />
+      <TextInput
+        name={`${name}.rounds`}
+        label='Rounds'
+        value={value.rounds || undefined}
+        type='number'
+        onChange={onChange}
+      />
+      <TextInput
+        name={`${name}.rest`}
+        label='Rest'
+        value={value.rest || undefined}
+        type='number'
+        onChange={onChange}
+      />
+    </fieldset>
+  )
+}
+
+export default BlockFormInputs

+ 7 - 68
frontend/src/training/components/EditBlock.tsx

@@ -1,80 +1,19 @@
-import { get } from 'lodash'
-import { useFormatsQuery } from '../../gql'
-import FormatSelector from './FormatSelector'
+import { BlockCreateInput } from '../../gql'
+import BlockFormInputs from './BlockFormInputs'
 
 const EditBlock = ({
   onChange,
   value,
   name
 }: {
-  onChange: any
-  value: any
+  onChange: GenericEventHandler
+  value: BlockCreateInput
   name: string
 }) => {
-  const sequence = `${name}.sequence`
-  const title = `${name}.title`
-  const description = `${name}.description`
-  const duration = `${name}.duration`
-  const rounds = `${name}.rounds`
-  const format = `${name}.format`
-  const rest = `${name}.rest`
-
   return (
-    <>
-      <label>Sequence</label>
-      <input
-        type='number'
-        name={sequence}
-        id={sequence}
-        value={get(value, sequence)}
-        onChange={onChange}
-      />
-      <label>Title</label>
-      <input
-        type='text'
-        name={title}
-        id={title}
-        value={get(value, title)}
-        onChange={onChange}
-      />
-      <label>Description</label>
-      <input
-        type='text'
-        name={description}
-        id={description}
-        value={get(value, description)}
-        onChange={onChange}
-      />
-      <label>duration</label>
-      <input
-        type='number'
-        name={duration}
-        id={duration}
-        value={get(value, duration)}
-        onChange={onChange}
-      />
-      <label>Rounds</label>
-      <FormatSelector
-        name={format}
-        value={get(value, format)}
-        onChange={onChange}
-      />
-      <input
-        type='number'
-        name={rounds}
-        id={rounds}
-        value={get(value, rounds)}
-        onChange={onChange}
-      />
-      <label>Rest</label>
-      <input
-        type='number'
-        name={rest}
-        id={rest}
-        value={get(value, rest)}
-        onChange={onChange}
-      />
-    </>
+    <form>
+      <BlockFormInputs name={name} value={value} onChange={onChange} />
+    </form>
   )
 }
 

+ 52 - 56
frontend/src/training/components/EditTraining.tsx

@@ -1,30 +1,37 @@
 import {
   useTrainingsQuery,
   useCreateTrainingMutation,
-  BlockCreateInput
+  CreateTrainingMutationVariables,
+  BlockCreateManyInput
 } from '../../gql'
-import { useForm } from '../../form/hooks/useForm'
-import EditBlock from './EditBlock'
+import { useForm, TextInput, Checkbox, DateTimeInput } from '../../form'
+import BlockFormInputs from './BlockFormInputs'
 import TrainingTypeSelector from './TrainingTypeSelector'
 
 const TrainingList = () => {
   const { data, error, loading } = useTrainingsQuery()
 
   return (
-    <ul>{data && data.trainings.map(training => <li>{training.id}</li>)}</ul>
+    <ul>
+      {data &&
+        data.trainings.map(training => (
+          <li key={training.id}>{training.id}</li>
+        ))}
+    </ul>
   )
 }
 
 const NewTraining = ({ id }: { id?: string }) => {
-  const { values, touched, handleChange } = useForm({
+  const { values, touched, ...props } = useForm({
     title: '',
-    type: undefined,
-    trainingDate: '',
     location: '',
     attendance: 0,
-    published: false,
-    blocks: [] as BlockCreateInput[]
-  })
+    trainingDate: new Date().toISOString(),
+    type: { connect: { id: '' } },
+    blocks: { connect: [], create: [] },
+    published: false
+  } as CreateTrainingMutationVariables)
+  console.log(values.trainingDate)
   const [createTraining, createData] = useCreateTrainingMutation()
 
   return (
@@ -34,72 +41,61 @@ const NewTraining = ({ id }: { id?: string }) => {
       <form
         onSubmit={ev => {
           ev.preventDefault()
-          createTraining({
-            variables: {
-              ...values,
-              type: { connect: { id: values.type } }
-            }
-          })
+          createTraining({ variables: values })
         }}
       >
-        <label>Title</label>
-        <input
-          type='text'
-          name='title'
-          id='title'
-          value={values.title}
-          onChange={handleChange}
-        />
-        <TrainingTypeSelector value={values.type} onChange={handleChange} />
-        <label>Training date</label>
-        <input
-          type='date'
+        <TextInput name='title' label='Title' value={values.title} {...props} />
+        <TrainingTypeSelector value={values.type} {...props} />
+        <DateTimeInput
           name='trainingDate'
-          id='trainingDate'
+          label='Training date'
           value={values.trainingDate}
-          onChange={handleChange}
+          {...props}
         />
-        <label>Location</label>
-        <input
-          type='text'
+        <TextInput
           name='location'
-          id='location'
+          label='Location'
           value={values.location}
-          onChange={handleChange}
+          {...props}
         />
-        <label>Attendance</label>
-        <input
-          type='number'
+        <TextInput
           name='attendance'
-          id='attendance'
+          label='Attendance'
+          type='number'
           value={values.attendance}
-          onChange={handleChange}
+          {...props}
         />
-        <label>Published</label>
-        <input
-          type='checkbox'
+        <Checkbox
           name='published'
-          id='published'
-          checked={values.published}
-          onChange={handleChange}
+          label='Published'
+          value={values.published}
+          {...props}
         />
         <label>Blocks</label>
-        {values.blocks.map((block, index) => (
-          <EditBlock
-            key={index}
-            name={`blocks.${index}`}
-            value={block}
-            onChange={handleChange}
-          />
-        ))}
+        {values.blocks &&
+          values.blocks.map((block, index) => (
+            <BlockFormInputs
+              key={index}
+              name={`blocks.${index}`}
+              value={block}
+              {...props}
+            />
+          ))}
         <button
           onClick={event => {
             event.preventDefault()
-            handleChange({
+            const { onChange } = props
+            onChange({
               target: {
                 type: 'custom',
                 name: 'blocks',
-                value: [...values.blocks, { sequence: 0, title: '' }]
+                value: {
+                  create:
+                    values.blocks && values.blocks.create
+                      ? [...values.blocks.create]
+                      : [{ title: '' }],
+                  connect: []
+                }
               }
             })
           }}

+ 26 - 31
frontend/src/training/components/FormatSelector.tsx

@@ -1,32 +1,31 @@
-import { useFormatsQuery } from "../../gql";
-import { ChangeEvent } from "react";
+import { useFormatsQuery, FormatCreateOneInput } from '../../gql'
 
 interface IFormatSelector {
-  value: { connect: { id: string } } | undefined;
-  name?: string;
-  label?: string;
-  onChange: (event: ChangeEvent<HTMLSelectElement> | NestedEvent) => void;
+  value?: FormatCreateOneInput
+  name?: string
+  label?: string
+  onChange: GenericEventHandler
 }
 
 const FormatSelector = ({
   onChange,
   value,
-  name = "format",
-  label = "Format",
+  name = 'format',
+  label = 'Format',
   ...props
 }: IFormatSelector) => {
-  const formats = useFormatsQuery();
-  const id = value && value.connect.id;
+  const formats = useFormatsQuery()
+  const id = value && value.connect && value.connect.id
 
-  if (formats.data && formats.data.formats.length > 0 && !value) {
-    const id = formats.data.formats[0].id;
+  if (formats.data && formats.data.formats.length > 0 && !id) {
+    const id = formats.data.formats[0].id
     onChange({
       target: {
-        type: "custom",
+        type: 'custom',
         value: { connect: { id } },
         name
       }
-    });
+    })
   }
 
   return (
@@ -35,42 +34,38 @@ const FormatSelector = ({
       <select
         id={name}
         name={name}
-        value={id}
+        value={id || undefined}
         onChange={event => {
-          const copy: NestedEvent = {
+          const copy: CustomChangeEvent = {
             target: {
-              type: "custom",
+              type: 'custom',
               value: { connect: { id: event.target.value } },
               name
             }
-          };
-          onChange(copy);
+          }
+          onChange(copy)
         }}
         {...props}
       >
-        {formats.loading && "loading formats..."}
-        {formats.error && "error loading formats"}
+        {formats.loading && 'loading formats...'}
+        {formats.error && 'error loading formats'}
         {formats.data &&
           formats.data.formats.map(format => (
-            <option
-              key={format.id}
-              value={format.id}
-              selected={id === format.id}
-            >
+            <option key={format.id} value={format.id}>
               {format.name}
             </option>
           ))}
       </select>
       <button
-        type="button"
+        type='button'
         onClick={event => {
-          event.preventDefault();
+          event.preventDefault()
         }}
       >
         Add format
       </button>
     </>
-  );
-};
+  )
+}
 
-export default FormatSelector;
+export default FormatSelector

+ 26 - 31
frontend/src/training/components/TrainingTypeSelector.tsx

@@ -1,36 +1,35 @@
-import { ChangeEvent } from "react";
-import { useTrainingTypesQuery } from "../../gql";
+import { useTrainingTypesQuery, TrainingTypeCreateOneInput } from '../../gql'
 
 interface ITrainingTypeSelector {
-  value: { connect: { id: string } } | undefined;
-  label?: string;
-  name?: string;
-  onChange: (event: ChangeEvent<HTMLSelectElement> | NestedEvent) => void;
+  value?: TrainingTypeCreateOneInput
+  label?: string
+  name?: string
+  onChange: GenericEventHandler
 }
 
 const TrainingTypeSelector = ({
   onChange,
   value,
-  name = "type",
-  label = "Training type",
+  name = 'type',
+  label = 'Training type',
   ...props
 }: ITrainingTypeSelector) => {
-  const trainingTypes = useTrainingTypesQuery();
-  const id = value && value.connect.id;
+  const trainingTypes = useTrainingTypesQuery()
+  const id = value && value.connect && value.connect.id
 
   if (
     trainingTypes.data &&
     trainingTypes.data.trainingTypes.length > 0 &&
-    !value
+    !id
   ) {
-    const id = trainingTypes.data.trainingTypes[0].id;
+    const id = trainingTypes.data.trainingTypes[0].id
     onChange({
       target: {
-        type: "custom",
+        type: 'custom',
         value: { connect: { id } },
         name
       }
-    });
+    })
   }
 
   return (
@@ -39,41 +38,37 @@ const TrainingTypeSelector = ({
       <select
         id={name}
         name={name}
-        value={id}
+        value={id || undefined}
         onChange={event => {
-          const copy: NestedEvent = {
+          const copy: CustomChangeEvent = {
             target: {
-              type: "custom",
+              type: 'custom',
               value: { connect: { id: event.target.value } },
               name
             }
-          };
-          onChange(copy);
+          }
+          onChange(copy)
         }}
         {...props}
       >
-        {trainingTypes.loading && "loading training types..."}
-        {trainingTypes.error && "error loading training types"}
+        {trainingTypes.loading && 'loading training types...'}
+        {trainingTypes.error && 'error loading training types'}
         {trainingTypes.data &&
           trainingTypes.data.trainingTypes.map(trainingType => (
-            <option
-              key={trainingType.id}
-              value={trainingType.id}
-              selected={id === trainingType.id}
-            >
+            <option key={trainingType.id} value={trainingType.id}>
               {trainingType.name}
             </option>
           ))}
       </select>
       <button
-        type="button"
+        type='button'
         onClick={event => {
-          event.preventDefault();
+          event.preventDefault()
         }}
       >
         Add type
       </button>
     </>
-  );
-};
-export default TrainingTypeSelector;
+  )
+}
+export default TrainingTypeSelector

+ 1 - 1
frontend/src/training/training.graphql

@@ -47,7 +47,7 @@ mutation createTraining(
   $location: String!
   $attendance: Int!
   $published: Boolean!
-  $blocks: [BlockCreateInput!]!
+  $blocks: BlockCreateManyInput
 ) {
   createTraining(
     title: $title

+ 5 - 1
frontend/src/training/types.ts

@@ -30,7 +30,11 @@ export interface IBlock {
   exercises?: IExercise[]
 }
 
-export interface IFormat {}
+export interface IFormat {
+  id: string
+  name: string
+  description: string
+}
 
 export interface IExercise {
   id: string