diff --git a/apps/admin-server/src/pages/projects/[project]/widgets/choiceguide/[id]/items.tsx b/apps/admin-server/src/pages/projects/[project]/widgets/choiceguide/[id]/items.tsx index 1662fb49e..36ce9d192 100644 --- a/apps/admin-server/src/pages/projects/[project]/widgets/choiceguide/[id]/items.tsx +++ b/apps/admin-server/src/pages/projects/[project]/widgets/choiceguide/[id]/items.tsx @@ -356,7 +356,16 @@ export default function WidgetChoiceGuideItems( }, [ form.watch('type') ]) function handleSaveItems() { - props.updateConfig({ ...props, items }); + const updatedProps = { ...props }; + + Object.keys(updatedProps).forEach((key: string) => { + if (key.startsWith("options.")) { + // @ts-ignore + delete updatedProps[key]; + } + }); + + props.updateConfig({ ...updatedProps, items }); window.location.reload(); } diff --git a/apps/admin-server/src/pages/projects/[project]/widgets/enquete/[id]/items.tsx b/apps/admin-server/src/pages/projects/[project]/widgets/enquete/[id]/items.tsx index 5290ee616..587ede8ea 100644 --- a/apps/admin-server/src/pages/projects/[project]/widgets/enquete/[id]/items.tsx +++ b/apps/admin-server/src/pages/projects/[project]/widgets/enquete/[id]/items.tsx @@ -30,6 +30,7 @@ import * as z from 'zod'; import InfoDialog from '@/components/ui/info-hover'; import { useRouter } from 'next/router'; import {YesNoSelect} from "@/lib/form-widget-helpers"; +import {ProjectSettingProps} from "@openstad-headless/types"; const formSchema = z.object({ trigger: z.string(), @@ -45,18 +46,17 @@ const formSchema = z.object({ .array( z.object({ trigger: z.string(), - titles: z.array(z.object({ text: z.string().optional(), key: z.string(), isOtherOption: z.boolean().optional(), defaultValue: z.boolean().optional() })), + titles: z.array(z.object({ + text: z.string().optional(), + key: z.string(), + image: z.string().optional(), + isOtherOption: z.boolean().optional(), + defaultValue: z.boolean().optional(), + hideLabel: z.boolean().optional() + })), }) ) .optional(), - image1Upload: z.string().optional(), - image1: z.string().optional(), - text1: z.string().optional(), - key1: z.string().optional(), - image2: z.string().optional(), - image2Upload: z.string().optional(), - text2: z.string().optional(), - key2: z.string().optional(), multiple: z.boolean().optional(), image: z.string().optional(), imageAlt: z.string().optional(), @@ -66,6 +66,17 @@ const formSchema = z.object({ showSmileys: z.boolean().optional(), placeholder: z.string().optional(), defaultValue: z.string().optional(), + imageOptionUpload: z.string().optional(), + + // Keeping these for backwards compatibility + image1Upload: z.string().optional(), + image1: z.string().optional(), + text1: z.string().optional(), + key1: z.string().optional(), + image2: z.string().optional(), + image2Upload: z.string().optional(), + text2: z.string().optional(), + key2: z.string().optional(), }); export default function WidgetEnqueteItems( @@ -109,12 +120,6 @@ export default function WidgetEnqueteItems( maxCharacters: values.maxCharacters, variant: values.variant || 'text input', options: values.options || [], - image1: values.image1 || '', - text1: values.text1 || '', - key1: values.key1 || '', - image2: values.image2 || '', - text2: values.text2 || '', - key2: values.key2 || '', multiple: values.multiple || false, image: values.image || '', imageAlt: values.imageAlt || '', @@ -123,6 +128,14 @@ export default function WidgetEnqueteItems( showSmileys: values.showSmileys || false, defaultValue: values.defaultValue || '', placeholder: values.placeholder || '', + + // Keeping these for backwards compatibility + image1: values.image1 || '', + text1: values.text1 || '', + key1: values.key1 || '', + image2: values.image2 || '', + text2: values.text2 || '', + key2: values.key2 || '', }, ]); } @@ -172,12 +185,6 @@ export default function WidgetEnqueteItems( maxCharacters: '', variant: 'text input', options: [], - image1: '', - text1: '', - key1: '', - image2: '', - text2: '', - key2: '', multiple: false, image: '', imageAlt: '', @@ -186,6 +193,14 @@ export default function WidgetEnqueteItems( showSmileys: false, defaultValue: '', placeholder: '', + + // Keeping these for backwards compatibility + image1: '', + text1: '', + key1: '', + image2: '', + text2: '', + key2: '', }); const form = useForm({ @@ -217,12 +232,6 @@ export default function WidgetEnqueteItems( maxCharacters: selectedItem.maxCharacters || '', variant: selectedItem.variant || '', options: selectedItem.options || [], - image1: selectedItem.image1 || '', - text1: selectedItem.text1 || '', - key1: selectedItem.key1 || '', - image2: selectedItem.image2 || '', - text2: selectedItem.text2 || '', - key2: selectedItem.key2 || '', multiple: selectedItem.multiple || false, image: selectedItem.image || '', imageAlt: selectedItem.imageAlt || '', @@ -231,6 +240,14 @@ export default function WidgetEnqueteItems( showSmileys: selectedItem.showSmileys || false, defaultValue: selectedItem.defaultValue || '', placeholder: selectedItem.placeholder || '', + + // Keeping these for backwards compatibility + image1: selectedItem.image1 || '', + text1: selectedItem.text1 || '', + key1: selectedItem.key1 || '', + image2: selectedItem.image2 || '', + text2: selectedItem.text2 || '', + key2: selectedItem.key2 || '', }); setOptions(selectedItem.options || []); } @@ -304,14 +321,24 @@ export default function WidgetEnqueteItems( } function handleSaveItems() { - props.updateConfig({ ...props, items }); + const updatedProps = { ...props }; + + Object.keys(updatedProps).forEach((key: string) => { + if (key.startsWith("options.")) { + // @ts-ignore + delete updatedProps[key]; + } + }); + + props.updateConfig({ ...updatedProps, items }); } + const hasOptions = () => { switch (form.watch('questionType')) { case 'multiplechoice': - return true; case 'multiple': + case 'images': return true; default: return false; @@ -321,8 +348,8 @@ export default function WidgetEnqueteItems( const hasList = () => { switch (form.watch('questionType')) { case 'multiplechoice': - return true; case 'multiple': + case 'images': return true; default: return false; @@ -428,7 +455,7 @@ export default function WidgetEnqueteItems( const currentOption = options.findIndex((option) => option.trigger === selectedOption?.trigger); const activeOption = currentOption !== -1 ? currentOption : options.length; - return ( + return form.watch("questionType") !== "images" ? ( <> )} + + ) : ( + <> + { + const image = imageResult ? imageResult.url : ''; + form.setValue(`options.${activeOption}.titles.0.image`, image); + form.resetField('imageOptionUpload'); + }} + /> - + {!!form.getValues(`options.${activeOption}.titles.0.image`) && ( +
+ +
+ )} + + ( + + Titel + + Dit veld wordt gebruikt voor de alt tekst van de afbeelding. Dit is nodig voor toegankelijkheid. + De titel wordt ook gebruikt als bijschrift onder de afbeelding, behalve als je de optie selecteert om de titel te verbergen. + + + + + )} + /> + + ( + <> + + {YesNoSelect(field, props)} + Titel verbergen? + + + + Als je deze optie selecteert, wordt de titel van de afbeelding verborgen. + + + )} + /> + ); })() )} @@ -688,7 +777,7 @@ export default function WidgetEnqueteItems( Informatie blok - Twee antwoordopties met afbeeldingen + Antwoordopties met afbeeldingen Multiplechoice @@ -804,96 +893,6 @@ export default function WidgetEnqueteItems( )} - {form.watch('questionType') === 'images' && ( - <> - { - const image = imageResult ? imageResult.url : ''; - - form.setValue("image1", image); - form.resetField('image1Upload'); - }} - /> - - {!!form.getValues('image1') && ( -
- -
- )} - - ( - - Key afbeelding 1 - - - - )} - /> - ( - - Titel afbeelding 1 - - - - )} - /> - - { - const image = imageResult ? imageResult.url : ''; - - form.setValue("image2", image); - form.resetField('image2Upload'); - }} - /> - - {!!form.getValues('image2') && ( -
- -
- )} - - ( - - Key afbeelding 2 - - - - )} - /> - ( - - Titel afbeelding 2 - - - - )} - /> - - )} - {form.watch('questionType') === 'imageUpload' && ( { + if (key.startsWith("options.")) { + // @ts-ignore + delete updatedProps[key]; + } + }); + + props.updateConfig({ ...updatedProps, items }); } const hasOptions = () => { diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 09060c699..fabcbb03c 100755 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -4,7 +4,7 @@ "description": "OpenStad API server", "main": "server.js", "scripts": { - "start": "node ./scripts/migrate-database.js && node server.js", + "start": "node server.js", "predev": "./scripts/predev.sh", "dev": "node nodemon-watch-server.js", "lint": "node ./scripts/lint", diff --git a/apps/api-server/scripts/init-database.js b/apps/api-server/scripts/init-database.js index 2ba26b323..59b649492 100755 --- a/apps/api-server/scripts/init-database.js +++ b/apps/api-server/scripts/init-database.js @@ -7,85 +7,88 @@ const db = require('../src/db'); const { Umzug, SequelizeStorage } = require('umzug'); (async () => { - const args = process.argv.slice(2); - - if (args.length > 0 && args[0] == '--only-if-empty') { + const resetDatabase = async () => { try { - // Check if there are tables in the database - const tables = await db.sequelize.query('SHOW TABLES'); - - if (tables && tables[0].length > 0) { - console.log ('--only-if-empty was given, but database is not empty, skipping initialization'); - process.exit(0); - } + + const umzug = new Umzug({ + migrations: { + glob: './migrations/*.js', + params: [ + db.sequelize.getQueryInterface(), + db.Sequelize // Sequelize constructor - the required module + ], + }, + context: db.sequelize.getQueryInterface(), + storage: new SequelizeStorage({ sequelize: db.sequelize, tableName : 'migrations' }), + logger: console, + }); - } catch (err) { - console.log ('--only-if-empty was given, but we encountered an error while checking database, skipping initialization'); - process.exit(0); - } - } + console.log('Create database...'); + await db.sequelize.query('SET FOREIGN_KEY_CHECKS = 0', { raw: true }) + await db.sequelize.sync ({ force: true }); - try { - - const umzug = new Umzug({ - migrations: { - glob: './migrations/*.js', - params: [ - db.sequelize.getQueryInterface(), - db.Sequelize // Sequelize constructor - the required module - ], - }, - context: db.sequelize.getQueryInterface(), - storage: new SequelizeStorage({ sequelize: db.sequelize, tableName : 'migrations' }), - logger: console, - }); - - console.log('Create database...'); - await db.sequelize.query('SET FOREIGN_KEY_CHECKS = 0', { raw: true }) - await db.sequelize.sync ({ force: true }); - - console.log('Marking migrations as done...'); - let pendingMigrations = await umzug.pending(); - for (let migration of pendingMigrations) { - await umzug.storage.logMigration(migration) - } - - console.log('Adding data...'); - - let datafile = 'default'; - try { - await require(`../seeds/${datafile}`)(config, db); - } catch(err) { + console.log('Marking migrations as done...'); + let pendingMigrations = await umzug.pending(); + for (let migration of pendingMigrations) { + await umzug.storage.logMigration(migration) + } + + console.log('Adding data...'); + + let datafile = 'default'; + try { + await require(`../seeds/${datafile}`)(config, db); + } catch(err) { + console.log(err); + } + + datafile = process.env.NODE_ENV || 'development'; + try { + await require(`../seeds/${datafile}`)(config, db); + } catch(err) { + if (err && err.message && err.message.match(/Cannot find module/)) { + console.log(` no ${datafile} data seeds found`); + } else { + console.log(err.message); + } + } + + datafile = 'local'; + try { + await require(`../seeds/${datafile}`)(config, db); + } catch(err) { + if (err && err.message && err.message.match(/Cannot find module/)) { + console.log(' no local data seeds found'); + } else { + console.log(err.message); + } + } + + } catch (err) { console.log(err); + } finally { + db.sequelize.close(); + process.exit(); } + } - datafile = process.env.NODE_ENV || 'development'; - try { - await require(`../seeds/${datafile}`)(config, db); - } catch(err) { - if (err && err.message && err.message.match(/Cannot find module/)) { - console.log(` no ${datafile} data seeds found`); - } else { - console.log(err.message); - } - } - - datafile = 'local'; + const args = process.argv.slice(2); + + const isDatabaseEmpty = async () => { try { - await require(`../seeds/${datafile}`)(config, db); - } catch(err) { - if (err && err.message && err.message.match(/Cannot find module/)) { - console.log(' no local data seeds found'); - } else { - console.log(err.message); - } + const tables = await db.sequelize.query('SHOW TABLES'); + return tables[0].length === 0 + } catch (err) { + console.error("Error while checking database tables:", err); + process.exit(0); } - - } catch (err) { - console.log(err); - } finally { - db.sequelize.close(); - process.exit(); } + if (await isDatabaseEmpty() || args?.includes('--force')) { + console.log('Initializing the database...'); + await resetDatabase(); + } else { + console.log('Skipping database initialization, because tables already exist. To force reset the database, use the --force flag.') + process.exit(0); + } })(); diff --git a/apps/auth-server/scripts/init-database.js b/apps/auth-server/scripts/init-database.js index 5dc4fb31e..d5ba67ae8 100755 --- a/apps/auth-server/scripts/init-database.js +++ b/apps/auth-server/scripts/init-database.js @@ -6,86 +6,88 @@ const db = require('../db'); const { Umzug, SequelizeStorage } = require('umzug'); (async () => { - - const args = process.argv.slice(2); - - if (args.length > 0 && args[0] == '--only-if-empty') { + const resetDatabase = async () => { try { - // Check if there are tables in the database - const tables = await db.sequelize.query('SHOW TABLES'); - - if (tables && tables[0].length > 0) { - console.log ('--only-if-empty was given, but database is not empty, skipping initialization'); - process.exit(0); - } + + const umzug = new Umzug({ + migrations: { + glob: './migrations/*.js', + params: [ + db.sequelize.getQueryInterface(), + db.Sequelize // Sequelize constructor - the required module + ], + }, + context: db.sequelize.getQueryInterface(), + storage: new SequelizeStorage({ sequelize: db.sequelize, tableName : 'migrations' }), + logger: console, + }); + console.log('Create database...'); + await db.sequelize.query('SET FOREIGN_KEY_CHECKS = 0', { raw: true }) + await db.sequelize.sync({ force: true }); + + console.log('Marking migrations as done...'); + let pendingMigrations = await umzug.pending(); + for (let migration of pendingMigrations) { + await umzug.storage.logMigration(migration) + } + + console.log('Adding data...'); + + let datafile = 'default'; + try { + await require(`../seeds/${datafile}`)(db); + } catch(err) { + console.log(err); + } + + datafile = process.env.NODE_ENV || 'development'; + try { + await require(`../seeds/${datafile}`)(db); + } catch(err) { + if (err && err.message && err.message.match(/Cannot find module/)) { + console.log(` no ${datafile} data seeds found`); + } else { + console.log(err.message); + } + } + + datafile = 'local'; + try { + await require(`../seeds/${datafile}`)(db); + } catch(err) { + if (err && err.message && err.message.match(/Cannot find module/)) { + console.log(' no local data seeds found'); + } else { + console.log(err.message); + } + } + } catch (err) { - console.log ('--only-if-empty was given, but we encountered an error while checking database, skipping initialization'); + console.log(err); + } finally { + db.sequelize.close(); process.exit(0); } } - try { - - const umzug = new Umzug({ - migrations: { - glob: './migrations/*.js', - params: [ - db.sequelize.getQueryInterface(), - db.Sequelize // Sequelize constructor - the required module - ], - }, - context: db.sequelize.getQueryInterface(), - storage: new SequelizeStorage({ sequelize: db.sequelize, tableName : 'migrations' }), - logger: console, - }); - - console.log('Create database...'); - await db.sequelize.query('SET FOREIGN_KEY_CHECKS = 0', { raw: true }) - await db.sequelize.sync({ force: true }); - - console.log('Marking migrations as done...'); - let pendingMigrations = await umzug.pending(); - for (let migration of pendingMigrations) { - await umzug.storage.logMigration(migration) - } - - console.log('Adding data...'); - - let datafile = 'default'; - try { - await require(`../seeds/${datafile}`)(db); - } catch(err) { - console.log(err); - } - - datafile = process.env.NODE_ENV || 'development'; - try { - await require(`../seeds/${datafile}`)(db); - } catch(err) { - if (err && err.message && err.message.match(/Cannot find module/)) { - console.log(` no ${datafile} data seeds found`); - } else { - console.log(err.message); - } - } - - datafile = 'local'; + const args = process.argv.slice(2); + + const isDatabaseEmpty = async () => { try { - await require(`../seeds/${datafile}`)(db); - } catch(err) { - if (err && err.message && err.message.match(/Cannot find module/)) { - console.log(' no local data seeds found'); - } else { - console.log(err.message); - } + const tables = await db.sequelize.query('SHOW TABLES'); + return tables[0].length === 0 + } catch (err) { + console.error("Error while checking database tables:", err); + process.exit(0); } - - } catch (err) { - console.log(err); - } finally { - db.sequelize.close(); - process.exit(); } + if (await isDatabaseEmpty() || args?.includes('--force')) { + console.log('Initializing the database...'); + await resetDatabase(); + } else { + console.log('Skipping database initialization, because tables already exist. To force reset the database, use the --force flag.') + process.exit(0); + } })(); diff --git a/operations/deployments/openstad-headless/environments/acc/images.yml b/operations/deployments/openstad-headless/environments/acc/images.yml index 4e1914fed..9a92b47d5 100644 --- a/operations/deployments/openstad-headless/environments/acc/images.yml +++ b/operations/deployments/openstad-headless/environments/acc/images.yml @@ -1,15 +1,15 @@ admin: deploymentContainer: - image: ghcr.io/openstad/admin-server:develop-e388fae + image: ghcr.io/openstad/admin-server:develop-5e2b5aa api: deploymentContainer: - image: ghcr.io/openstad/api-server:develop-e388fae + image: ghcr.io/openstad/api-server:develop-5e2b5aa auth: deploymentContainer: - image: ghcr.io/openstad/auth-server:develop-e388fae + image: ghcr.io/openstad/auth-server:develop-5e2b5aa image: deploymentContainer: - image: ghcr.io/openstad/image-server:develop-e388fae + image: ghcr.io/openstad/image-server:develop-5e2b5aa cms: deploymentContainer: - image: ghcr.io/openstad/cms-server:develop-e388fae + image: ghcr.io/openstad/cms-server:develop-5e2b5aa diff --git a/operations/deployments/openstad-headless/environments/prod/images.yml b/operations/deployments/openstad-headless/environments/prod/images.yml index a4e2e5659..3a1ba6b09 100644 --- a/operations/deployments/openstad-headless/environments/prod/images.yml +++ b/operations/deployments/openstad-headless/environments/prod/images.yml @@ -12,4 +12,4 @@ image: image: ghcr.io/openstad/image-server:main-d045bf4 cms: deploymentContainer: - image: ghcr.io/openstad/cms-server:main-d045bf4 + image: ghcr.io/openstad/cms-server:main-d045bf4 \ No newline at end of file diff --git a/packages/comments/src/comments.tsx b/packages/comments/src/comments.tsx index a79d8f12f..397ed3d40 100644 --- a/packages/comments/src/comments.tsx +++ b/packages/comments/src/comments.tsx @@ -282,7 +282,7 @@ function CommentsInner({ ?.map((comment: any, index: number) => { let attributes = { ...args, comment, submitComment, setRefreshComments: refreshComments }; - return ; + return ; })} diff --git a/packages/comments/src/parts/comment.tsx b/packages/comments/src/parts/comment.tsx index 53079021d..69b07afe6 100644 --- a/packages/comments/src/parts/comment.tsx +++ b/packages/comments/src/parts/comment.tsx @@ -93,7 +93,11 @@ function Comment({ comments.forEach((comment) => comment.classList.remove('selected')); if (!isAlreadySelected) { - markerIcons[index]?.classList.toggle('--highlightedIcon'); + markerIcons.forEach((markerIcon) => { + if (markerIcon.classList.contains(`id-${index}`)) { + markerIcon.classList.add('--highlightedIcon'); + } + }) document.getElementById(`comment-${index}`)?.classList.toggle('selected'); } } @@ -129,7 +133,7 @@ function Comment({ } return ( -
+
{args.comment.user && args.comment.user.displayName}{' '} diff --git a/packages/document-map/src/document-map.tsx b/packages/document-map/src/document-map.tsx index 64655e303..ff284d4de 100644 --- a/packages/document-map/src/document-map.tsx +++ b/packages/document-map/src/document-map.tsx @@ -384,7 +384,7 @@ function DocumentMap({ }); const addNewCommentToComments = [...filteredComments, newComment]; - const newIndex = addNewCommentToComments.length - 1; + const newIndex = newComment?.id; setFilteredComments(addNewCommentToComments); setPopupPosition(null); @@ -515,7 +515,7 @@ function DocumentMap({ ref={markerRef} icon={MarkerIcon({ icon: { - className: `${index === selectedMarkerIndex ? '--highlightedIcon' : '--defaultIcon'} ${isDefaultColor ? 'basic-icon' : ''}`, + className: `${index === selectedMarkerIndex ? '--highlightedIcon' : '--defaultIcon'} ${isDefaultColor ? 'basic-icon' : ''} id-${index}`, color: !isDefaultColor ? color : undefined, }, })} @@ -735,7 +735,7 @@ function DocumentMap({ {filteredComments && filteredComments .filter((comment: any) => !!comment.location) - .map((comment: any, index: number) => { + .map((comment: any) => { const firstTag = comment.tags ? comment.tags @@ -747,9 +747,9 @@ function DocumentMap({ return ( diff --git a/packages/enquete/src/enquete.css b/packages/enquete/src/enquete.css index 45eec3d4d..9ff8456e1 100644 --- a/packages/enquete/src/enquete.css +++ b/packages/enquete/src/enquete.css @@ -100,12 +100,25 @@ } .question-type-imageChoice .image-choice-container { - display: flex; + display: grid; + grid-template-columns: 1fr 1fr; gap: 1rem; } -.question-type-imageChoice .image-choice-container > * { - flex: 1; +@media only screen and (max-width: 767px) { + .question-type-imageChoice .image-choice-container { + grid-template-columns: 1fr; + } +} + +.question-type-imageChoice .image-choice-container label figure figcaption { + margin-top: 10px; + display: flex; + align-items: flex-start; +} + +.question-type-imageChoice .image-choice-container > .utrecht-form-field--radio { + display: inline-block; } label.utrecht-form-label{ @@ -121,4 +134,4 @@ label.utrecht-form-label{ .question-type-text textarea, .question-type-text input[type="text"]{ max-width: none; -} \ No newline at end of file +} diff --git a/packages/enquete/src/enquete.tsx b/packages/enquete/src/enquete.tsx index d11c6d5f4..36f8c4a66 100644 --- a/packages/enquete/src/enquete.tsx +++ b/packages/enquete/src/enquete.tsx @@ -126,18 +126,31 @@ function Enquete(props: EnqueteWidgetProps) { break; case 'images': fieldData['type'] = 'imageChoice'; - fieldData['choices'] = [ - { - label: item?.text1 || '', - value: item?.key1 || '', - imageSrc: item?.image1 || '' - }, - { - label: item?.text2 || '', - value: item?.key2 || '', - imageSrc: item?.image2 || '' - } - ]; + + if ( item.options && item.options.length > 0 ) { + fieldData['choices'] = item.options.map((option) => { + return { + value: option.titles[0].key, + label: option.titles[0].key, + imageSrc: option.titles[0].image, + imageAlt: option.titles[0].key, + hideLabel: option.titles[0].hideLabel + }; + }); + } else { + fieldData['choices'] = [ + { + label: item?.text1 || '', + value: item?.key1 || '', + imageSrc: item?.image1 || '' + }, + { + label: item?.text2 || '', + value: item?.key2 || '', + imageSrc: item?.image2 || '' + } + ]; + } break; case 'imageUpload': diff --git a/packages/enquete/src/types/enquete-props.ts b/packages/enquete/src/types/enquete-props.ts index cdae5ed37..37f65baf0 100644 --- a/packages/enquete/src/types/enquete-props.ts +++ b/packages/enquete/src/types/enquete-props.ts @@ -22,12 +22,6 @@ export type Item = { minCharacters?: string; maxCharacters?: string; variant?: string; - image1?: string; - text1?: string; - key1?: string; - image2?: string; - text2?: string; - key2?: string; options?: Array