Skip to content
This repository was archived by the owner on Sep 9, 2025. It is now read-only.

Commit c6584f4

Browse files
authored
Merge pull request #45 from sdsc-ordes/feat/s3-search-by-date-parts
Feat/s3 search by date parts
2 parents 6a4945f + 9132a83 commit c6584f4

File tree

6 files changed

+204
-49
lines changed

6 files changed

+204
-49
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@types/archiver": "^6.0.3",
5353
"archiver": "^7.0.1",
5454
"dotenv": "^16.5.0",
55-
"pino": "^9.7.0"
55+
"pino": "^9.7.0",
56+
"zod": "^3.25.67"
5657
}
5758
}

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/components/ResultsHeaderData.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
</h1>
1010
{#if !resultsTotal}
1111
<p>
12-
No results have been found for this prefix: please try another search.
12+
No results have been found.
1313
</p>
1414
{/if}
Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,85 @@
11
<script lang="ts">
2-
import Database from '@lucide/svelte/icons/database';
3-
let {
4-
prefix = "batch/"
5-
}: {
6-
prefix: string;
7-
} = $props();
2+
import { enhance, applyAction } from '$app/forms';
3+
import { invalidateAll } from '$app/navigation';
4+
import Database from '@lucide/svelte/icons/database';
5+
6+
// 1. Receive initial props from the parent page.
7+
let { form, year, month, day, number } = $props();
8+
9+
// 2. Create local, mutable state for each input, initialized by the props.
10+
// This allows the user's typing to be tracked reactively.
11+
let yearValue = $state(year ?? '');
12+
let monthValue = $state(month ?? '');
13+
let dayValue = $state(day ?? '');
14+
let numberValue = $state(number ?? '');
15+
16+
function handleSubmit() {
17+
return async ({ result }: { result: any }) => {
18+
console.log('handleSubmit', result);
19+
if (result.type === 'redirect') {
20+
await invalidateAll();
21+
}
22+
await applyAction(result);
23+
};
24+
}
825
</script>
926

1027
<div class="space-y-4 rounded p-4">
11-
<h1 class="mb-4 text-2xl font-bold text-surface-800-200">Campaign Data</h1>
12-
<form method="POST" action="?/filter" class="space-y-4 p-4">
13-
<label class="label">
14-
<span>Filter Campaigns by Prefix:</span>
15-
<input name="prefix" class="input" type="string" value={prefix} required />
16-
</label>
17-
<div class="flex justify-start">
18-
<button type="submit" class="btn preset-filled-primary-500 w-full">
19-
<Database />Apply Path Filter
20-
</button>
21-
</div>
22-
</form>
23-
<div class="text-sm">
24-
<ul class="list-disc list-inside">
25-
<li>All Campaigns start with `batch`</li>
26-
<li>Next comes the year as `YYYY`</li>
27-
<li>the month as `MM`</li>
28-
<li>the day `DD`</li>
29-
<li>a number `NN`</li>
30-
</ul>
31-
<p>Example `batch/2024/05/16/22`</p>
32-
</div>
28+
<h1 class="mb-4 text-2xl font-bold text-surface-800-200">Campaign Data</h1>
29+
30+
<form method="POST" action="?/filter" class="space-y-4 p-4" use:enhance={handleSubmit}>
31+
<div class="label">
32+
<span>Filter Campaigns:</span>
33+
</div>
34+
35+
<label class="label">
36+
<span class="label-text">Year as YYYY</span>
37+
<input name="year" type="text" class="input" placeholder="YYYY" bind:value={yearValue} />
38+
{#if form?.errors?.year}
39+
<span class="text-sm text-red-500">{form.errors.year[0]}</span>
40+
{/if}
41+
</label>
42+
43+
{#if yearValue}
44+
<label class="label">
45+
<span class="label-text">Month as MM</span>
46+
<input name="month" type="text" class="input" placeholder="MM" bind:value={monthValue} />
47+
{#if form?.errors?.month}
48+
<span class="text-sm text-red-500">{form.errors.month[0]}</span>
49+
{/if}
50+
</label>
51+
{/if}
52+
53+
{#if yearValue && monthValue}
54+
<label class="label">
55+
<span class="label-text">Day as DD</span>
56+
<input name="day" type="text" class="input" placeholder="DD" bind:value={dayValue} />
57+
{#if form?.errors?.day}
58+
<span class="text-sm text-red-500">{form.errors.day[0]}</span>
59+
{/if}
60+
</label>
61+
{/if}
62+
63+
{#if yearValue && monthValue && dayValue}
64+
<label class="label">
65+
<span class="label-text">Number as NN</span>
66+
<input name="number" type="text" class="input" placeholder="NN" bind:value={numberValue} />
67+
{#if form?.errors?.number}
68+
<span class="text-sm text-red-500">{form.errors.number[0]}</span>
69+
{/if}
70+
</label>
71+
{/if}
72+
73+
{#if form?.success}
74+
<div class="rounded bg-green-200 p-2 text-green-800">
75+
{form.message}
76+
</div>
77+
{/if}
78+
79+
<div class="flex justify-start">
80+
<button type="submit" class="btn preset-filled-primary-500 w-full">
81+
<Database />Apply Path Filter
82+
</button>
83+
</div>
84+
</form>
3385
</div>

src/routes/data/+page.server.ts

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,83 @@
1-
import type { Actions } from '@sveltejs/kit';
21
import type { PageServerLoad } from './$types'
32
import { redirect } from '@sveltejs/kit';
43
import { type CampaignResult, prefixesToCampaignResults } from '$lib/utils/groupCampaigns';
54
import { findLeafPrefixes } from '$lib/server/s3';
65
import { logger } from '$lib/server/logger';
76

7+
import { z } from 'zod';
8+
import { fail } from '@sveltejs/kit';
9+
10+
// Define the validation schema with Zod
11+
const filterSchema = z.object({
12+
year: z.string()
13+
.regex(/^\d{4}$/, { message: "Year must be a 4-digit number" })
14+
.optional()
15+
.or(z.literal('')), // Allow empty string
16+
17+
month: z.preprocess(
18+
// If the input is an empty string, convert it to `undefined`.
19+
// Otherwise, pass it through unchanged.
20+
(val) => (val === "" ? undefined : val),
21+
// Now, validate the processed value.
22+
z.coerce.number({ invalid_type_error: "Month must be a number" })
23+
.int()
24+
.min(1, { message: "Month must be between 1 and 12" })
25+
.max(12, { message: "Month must be between 1 and 12" })
26+
.optional()
27+
),
28+
29+
day: z.preprocess(
30+
// Same preprocessing for the day field.
31+
(val) => (val === "" ? undefined : val),
32+
// Validate the processed day value.
33+
z.coerce.number({ invalid_type_error: "Day must be a number" })
34+
.int()
35+
.min(1, { message: "Day must be between 1 and 31" })
36+
.max(31, { message: "Day must be between 1 and 31" })
37+
.optional()
38+
),
39+
40+
number: z.string()
41+
.regex(/^\d{1,2}$/, { message: "Number must be a 1 or 2-digit number" })
42+
.optional()
43+
.or(z.literal('')),
44+
})
45+
// Your dependency rules are still correct and important!
46+
.superRefine((data, ctx) => {
47+
if (data.month && !data.year) {
48+
ctx.addIssue({
49+
code: z.ZodIssueCode.custom,
50+
path: ['year'],
51+
message: 'Year is required to specify a month',
52+
});
53+
}
54+
if (data.day && !data.month) {
55+
ctx.addIssue({
56+
code: z.ZodIssueCode.custom,
57+
path: ['month'],
58+
message: 'Month is required to specify a day',
59+
});
60+
}
61+
if (data.number && !data.day) {
62+
ctx.addIssue({
63+
code: z.ZodIssueCode.custom,
64+
path: ['day'],
65+
message: 'Day is required to specify a number',
66+
});
67+
}
68+
});
69+
870
export const load: PageServerLoad = async ({ locals, url }) => {
971
try {
10-
const prefix = url.searchParams.get('prefix') || 'batch/';
72+
const year = url.searchParams.get('year') || undefined;
73+
const rawMonth = url.searchParams.get('month') || undefined;
74+
const rawDay = url.searchParams.get('day') || undefined;
75+
const number = url.searchParams.get('number') || undefined;
76+
const month = rawMonth ? rawMonth.padStart(2, '0') : undefined;
77+
const day = rawDay ? rawDay.padStart(2, '0') : undefined;
78+
const pathParts = ['batch', year, month, day, number].filter(part => part);
79+
const prefix = pathParts.join('/');
80+
logger.info({prefix}, `Search campaigns with prefix ${prefix}`)
1181

1282
const { prefixes, count } = await findLeafPrefixes(prefix, 5);
1383
logger.info({prefixes, count}, `got campaign prefixes for start prefix ${prefix}`)
@@ -24,16 +94,35 @@ export const load: PageServerLoad = async ({ locals, url }) => {
2494
}
2595
};
2696

27-
export const actions: Actions = {
28-
filter: async ({ request, url }) => {
29-
const formData = await request.formData();
30-
const prefix = formData.get('prefix') || 'batch/';
97+
export const actions = {
98+
filter: async ({ request, url }) => {
99+
const formData = await request.formData();
100+
const data = Object.fromEntries(formData);
101+
102+
const result = filterSchema.safeParse(data);
103+
104+
if (!result.success) {
105+
// The `fail` function sends back a 400 status code
106+
// and the validation errors, along with the original data.
107+
return fail(400, {
108+
data: data,
109+
errors: result.error.flatten().fieldErrors,
110+
});
111+
}
31112

32-
// Create a URL object based on the current page's URL
33-
const targetUrl = new URL(url.origin + url.pathname);
34-
targetUrl.searchParams.set('prefix', prefix);
113+
logger.info('Validation successful! Applying filter with:', result.data);
35114

36-
// Use status 303 (See Other) for the redirect status code
115+
const targetUrl = new URL(url.origin + url.pathname)
116+
logger.debug(result.data, 'result.data');
117+
118+
for (const [key, value] of Object.entries(result.data)) {
119+
if (value) {
120+
targetUrl.searchParams.set(key, value as string);
121+
}
122+
}
123+
logger.info(`Redirecting to ${targetUrl}`);
124+
125+
// Use status 303
37126
throw redirect(303, targetUrl);
38-
}
127+
}
39128
};

src/routes/data/+page.svelte

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
11
<script lang="ts">
2-
import { page as routePage } from '$app/state';
2+
import { page } from '$app/state';
33
import ContentLayout from '$lib/components/ContentLayout.svelte';
44
import DisplayS3Results from '$lib/components/DisplayS3Results.svelte';
55
import S3SearchForm from '$lib/components/S3SearchForm.svelte';
66
import ResultsHeaderData from '$lib/components/ResultsHeaderData.svelte';
77
import type { CampaignResult } from '$lib/utils/groupCampaigns.js';
88
9-
// Get prefix from parameters
10-
let prefix = routePage.url.searchParams.get('prefix') || 'batch/';
9+
let year = $derived(page.url.searchParams.get('year') || undefined);
10+
let month = $derived(page.url.searchParams.get('month') || undefined);
11+
let day = $derived(page.url.searchParams.get('day') || undefined);
12+
let number = $derived(page.url.searchParams.get('number') || undefined);
1113
12-
// get props from data loader
13-
let { data } = $props();
14-
const results: CampaignResult[] = data.results;
15-
const resultsTotal: number = data.resultTotal;
14+
let { data, form } = $props();
15+
const results: CampaignResult[] = $derived(data.results);
16+
const resultsTotal: number = $derived(data.resultTotal);
1617
1718
// Result Display
1819
const HeadersS3Results: string[] = ["Campaign", "Date"]
1920
</script>
2021

2122
{#snippet sidebar()}
2223
<S3SearchForm
23-
prefix={prefix}
24+
form={form}
25+
year={year}
26+
month={month}
27+
day={day}
28+
number={number}
2429
/>
2530
{/snippet}
2631

0 commit comments

Comments
 (0)