Skip to content

Commit 1b078b8

Browse files
committed
add support for metadata keys in events API responses
1 parent 888eaaa commit 1b078b8

File tree

3 files changed

+208
-2
lines changed

3 files changed

+208
-2
lines changed

src/api/routes/events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
validatorCompiler,
4242
} from "fastify-zod-openapi";
4343
import { ts, withRoles, withTags } from "api/components/index.js";
44+
import { MAX_METADATA_KEYS, metadataSchema } from "common/types/events.js";
4445

4546
const repeatOptions = ["weekly", "biweekly"] as const;
4647
export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${EVENT_CACHED_DURATION}, stale-while-revalidate=420, stale-if-error=3600`;
@@ -56,6 +57,7 @@ const baseSchema = z.object({
5657
host: z.enum(OrganizationList as [string, ...string[]]),
5758
featured: z.boolean().default(false),
5859
paidEventId: z.optional(z.string().min(1)),
60+
metadata: metadataSchema,
5961
});
6062

6163
const requestSchema = baseSchema.extend({

src/common/types/events.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { z } from "zod";
2+
3+
export const MAX_METADATA_KEYS = 10;
4+
export const MAX_STRING_LENGTH = 100;
5+
6+
export const metadataSchema = z
7+
.record(z.string())
8+
.optional()
9+
.superRefine((metadata, ctx) => {
10+
if (!metadata) return;
11+
12+
const keys = Object.keys(metadata);
13+
14+
if (keys.length > MAX_METADATA_KEYS) {
15+
ctx.addIssue({
16+
code: z.ZodIssueCode.custom,
17+
message: `Metadata may have at most ${MAX_METADATA_KEYS} keys.`,
18+
});
19+
}
20+
21+
for (const key of keys) {
22+
if (key.length > MAX_STRING_LENGTH) {
23+
ctx.addIssue({
24+
code: z.ZodIssueCode.custom,
25+
message: `Metadata key "${key}" exceeds ${MAX_STRING_LENGTH} characters.`,
26+
});
27+
}
28+
29+
const value = metadata[key];
30+
if (value.length > MAX_STRING_LENGTH) {
31+
ctx.addIssue({
32+
code: z.ZodIssueCode.custom,
33+
message: `Metadata value for key "${key}" exceeds ${MAX_STRING_LENGTH} characters.`,
34+
});
35+
}
36+
}
37+
});

src/ui/pages/events/ManageEvent.page.tsx

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
import { Title, Box, TextInput, Textarea, Switch, Select, Button, Loader } from '@mantine/core';
1+
import {
2+
Title,
3+
Box,
4+
TextInput,
5+
Textarea,
6+
Switch,
7+
Select,
8+
Button,
9+
Loader,
10+
Group,
11+
ActionIcon,
12+
Text,
13+
} from '@mantine/core';
214
import { DateTimePicker } from '@mantine/dates';
315
import { useForm, zodResolver } from '@mantine/form';
416
import { notifications } from '@mantine/notifications';
@@ -7,11 +19,12 @@ import React, { useEffect, useState } from 'react';
719
import { useNavigate, useParams } from 'react-router-dom';
820
import { z } from 'zod';
921
import { AuthGuard } from '@ui/components/AuthGuard';
10-
import { getRunEnvironmentConfig } from '@ui/config';
1122
import { useApi } from '@ui/util/api';
1223
import { OrganizationList as orgList } from '@common/orgs';
1324
import { AppRoles } from '@common/roles';
1425
import { EVENT_CACHED_DURATION } from '@common/config';
26+
import { IconPlus, IconTrash } from '@tabler/icons-react';
27+
import { MAX_METADATA_KEYS, MAX_STRING_LENGTH, metadataSchema } from '@common/types/events';
1528

1629
export function capitalizeFirstLetter(string: string) {
1730
return string.charAt(0).toUpperCase() + string.slice(1);
@@ -29,6 +42,8 @@ const baseBodySchema = z.object({
2942
host: z.string().min(1, 'Host is required'),
3043
featured: z.boolean().default(false),
3144
paidEventId: z.string().min(1, 'Paid Event ID must be at least 1 character').optional(),
45+
// Add metadata field
46+
metadata: metadataSchema,
3247
});
3348

3449
const requestBodySchema = baseBodySchema
@@ -68,6 +83,7 @@ export const ManageEventPage: React.FC = () => {
6883
try {
6984
const response = await api.get(`/api/v1/events/${eventId}?ts=${Date.now()}`);
7085
const eventData = response.data;
86+
7187
const formValues = {
7288
title: eventData.title,
7389
description: eventData.description,
@@ -80,6 +96,7 @@ export const ManageEventPage: React.FC = () => {
8096
repeats: eventData.repeats,
8197
repeatEnds: eventData.repeatEnds ? new Date(eventData.repeatEnds) : undefined,
8298
paidEventId: eventData.paidEventId,
99+
metadata: eventData.metadata || {},
83100
};
84101
form.setValues(formValues);
85102
} catch (error) {
@@ -107,8 +124,10 @@ export const ManageEventPage: React.FC = () => {
107124
repeats: undefined,
108125
repeatEnds: undefined,
109126
paidEventId: undefined,
127+
metadata: {}, // Initialize empty metadata object
110128
},
111129
});
130+
112131
useEffect(() => {
113132
if (form.values.end && form.values.end <= form.values.start) {
114133
form.setFieldValue('end', new Date(form.values.start.getTime() + 3.6e6)); // 1 hour after the start date
@@ -124,6 +143,7 @@ export const ManageEventPage: React.FC = () => {
124143
const handleSubmit = async (values: EventPostRequest) => {
125144
try {
126145
setIsSubmitting(true);
146+
127147
const realValues = {
128148
...values,
129149
start: dayjs(values.start).format('YYYY-MM-DD[T]HH:mm:00'),
@@ -133,6 +153,7 @@ export const ManageEventPage: React.FC = () => {
133153
? dayjs(values.repeatEnds).format('YYYY-MM-DD[T]HH:mm:00')
134154
: undefined,
135155
repeats: values.repeats ? values.repeats : undefined,
156+
metadata: Object.keys(values.metadata || {}).length > 0 ? values.metadata : undefined,
136157
};
137158

138159
const eventURL = isEditing ? `/api/v1/events/${eventId}` : '/api/v1/events';
@@ -151,6 +172,87 @@ export const ManageEventPage: React.FC = () => {
151172
}
152173
};
153174

175+
// Function to add a new metadata field
176+
const addMetadataField = () => {
177+
const currentMetadata = { ...form.values.metadata };
178+
if (Object.keys(currentMetadata).length >= MAX_METADATA_KEYS) {
179+
notifications.show({
180+
message: `You can add at most ${MAX_METADATA_KEYS} metadata keys.`,
181+
});
182+
return;
183+
}
184+
185+
// Generate a temporary key name that doesn't exist yet
186+
let tempKey = `key${Object.keys(currentMetadata).length + 1}`;
187+
// Make sure it's unique
188+
while (currentMetadata[tempKey] !== undefined) {
189+
tempKey = `key${parseInt(tempKey.replace('key', '')) + 1}`;
190+
}
191+
192+
// Update the form
193+
form.setValues({
194+
...form.values,
195+
metadata: {
196+
...currentMetadata,
197+
[tempKey]: '',
198+
},
199+
});
200+
};
201+
202+
// Function to update a metadata value
203+
const updateMetadataValue = (key: string, value: string) => {
204+
form.setValues({
205+
...form.values,
206+
metadata: {
207+
...form.values.metadata,
208+
[key]: value,
209+
},
210+
});
211+
};
212+
213+
const updateMetadataKey = (oldKey: string, newKey: string) => {
214+
const metadata = { ...form.values.metadata };
215+
if (oldKey === newKey) return;
216+
217+
const value = metadata[oldKey];
218+
delete metadata[oldKey];
219+
metadata[newKey] = value;
220+
221+
form.setValues({
222+
...form.values,
223+
metadata,
224+
});
225+
};
226+
227+
// Function to remove a metadata field
228+
const removeMetadataField = (key: string) => {
229+
const currentMetadata = { ...form.values.metadata };
230+
delete currentMetadata[key];
231+
232+
form.setValues({
233+
...form.values,
234+
metadata: currentMetadata,
235+
});
236+
};
237+
238+
const [metadataKeys, setMetadataKeys] = useState<Record<string, string>>({});
239+
240+
// Initialize metadata keys with unique IDs when form loads or changes
241+
useEffect(() => {
242+
const newMetadataKeys: Record<string, string> = {};
243+
244+
// For existing metadata, create stable IDs
245+
Object.keys(form.values.metadata || {}).forEach((key) => {
246+
if (!metadataKeys[key]) {
247+
newMetadataKeys[key] = `meta-${Math.random().toString(36).substring(2, 9)}`;
248+
} else {
249+
newMetadataKeys[key] = metadataKeys[key];
250+
}
251+
});
252+
253+
setMetadataKeys(newMetadataKeys);
254+
}, [Object.keys(form.values.metadata || {}).length]);
255+
154256
return (
155257
<AuthGuard resourceDef={{ service: 'core', validRoles: [AppRoles.EVENTS_MANAGER] }}>
156258
<Box maw={400} mx="auto" mt="xl">
@@ -230,6 +332,71 @@ export const ManageEventPage: React.FC = () => {
230332
placeholder="Enter Ticketing ID or Merch ID prefixed with merch:"
231333
{...form.getInputProps('paidEventId')}
232334
/>
335+
336+
{/* Metadata Section */}
337+
<Box my="md">
338+
<Title order={5}>Metadata</Title>
339+
<Group justify="space-between" mb="xs">
340+
<Button
341+
size="xs"
342+
variant="outline"
343+
leftSection={<IconPlus size={16} />}
344+
onClick={addMetadataField}
345+
disabled={Object.keys(form.values.metadata || {}).length >= MAX_METADATA_KEYS}
346+
>
347+
Add Field
348+
</Button>
349+
</Group>
350+
<Text size="xs" c="dimmed">
351+
These values can be acceessed via the API. Max {MAX_STRING_LENGTH} characters for keys
352+
and values.
353+
</Text>
354+
355+
{Object.entries(form.values.metadata || {}).map(([key, value], index) => {
356+
const keyError = key.trim() === '' ? 'Key is required' : undefined;
357+
const valueError = value.trim() === '' ? 'Value is required' : undefined;
358+
359+
return (
360+
<Group key={index} align="start" gap={'sm'}>
361+
<TextInput
362+
label="Key"
363+
value={key}
364+
onChange={(e) => updateMetadataKey(key, e.currentTarget.value)}
365+
error={keyError}
366+
style={{ flex: 1 }}
367+
/>
368+
<Box style={{ flex: 1 }}>
369+
<TextInput
370+
label="Value"
371+
value={value}
372+
onChange={(e) => updateMetadataValue(key, e.currentTarget.value)}
373+
error={valueError}
374+
/>
375+
{/* Empty space to maintain consistent height */}
376+
{valueError && <div style={{ height: '0.75rem' }} />}
377+
</Box>
378+
<ActionIcon
379+
color="red"
380+
variant="light"
381+
onClick={() => removeMetadataField(key)}
382+
mt={30} // align with inputs when label is present
383+
>
384+
<IconTrash size={16} />
385+
</ActionIcon>
386+
</Group>
387+
);
388+
})}
389+
390+
{Object.keys(form.values.metadata || {}).length > 0 && (
391+
<Box mt="xs" size="xs" ta="right">
392+
<small>
393+
{Object.keys(form.values.metadata || {}).length} of {MAX_METADATA_KEYS} fields
394+
used
395+
</small>
396+
</Box>
397+
)}
398+
</Box>
399+
233400
<Button type="submit" mt="md">
234401
{isSubmitting ? (
235402
<>

0 commit comments

Comments
 (0)