Add Enable Dynamic Audio option to schedule blocks
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
initialStartMinutes = 9 * 60,
|
||||
initialEndMinutes = 10 * 60,
|
||||
initialProclamationIds = [],
|
||||
initialDynamicAudio = false,
|
||||
categories = [],
|
||||
groups = [],
|
||||
proclamations = [],
|
||||
@@ -42,6 +43,7 @@
|
||||
initialStartMinutes?: number;
|
||||
initialEndMinutes?: number;
|
||||
initialProclamationIds?: string[];
|
||||
initialDynamicAudio?: boolean;
|
||||
categories?: BrowserCategory[];
|
||||
groups?: BrowserGroup[];
|
||||
proclamations?: BrowserProclamation[];
|
||||
@@ -61,6 +63,7 @@
|
||||
let endMinute = $state('0');
|
||||
let endPeriod = $state<'AM' | 'PM'>('AM');
|
||||
let selectedIds = $state<string[]>([]);
|
||||
let dynamicAudio = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
|
||||
@@ -98,6 +101,7 @@
|
||||
endMinute = String(end.minute);
|
||||
endPeriod = end.period;
|
||||
selectedIds = [...initialProclamationIds];
|
||||
dynamicAudio = initialDynamicAudio;
|
||||
error = '';
|
||||
step = 'details';
|
||||
});
|
||||
@@ -209,6 +213,7 @@
|
||||
<input type="hidden" name="startMinutes" value={startMinutes} />
|
||||
<input type="hidden" name="endMinutes" value={endMinutes} />
|
||||
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
|
||||
<input type="hidden" name="dynamicAudio" value={dynamicAudio ? 'true' : 'false'} />
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
{#if error}
|
||||
@@ -313,6 +318,39 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dynamicAudio}
|
||||
onchange={(e) => (dynamicAudio = e.currentTarget.checked)}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
|
||||
/>
|
||||
Enable Dynamic Audio
|
||||
</label>
|
||||
<span class="group relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="What is Dynamic Audio?"
|
||||
aria-describedby="dynamic-audio-help"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border border-slate-300 text-xs font-semibold text-slate-500 transition hover:border-indigo-400 hover:text-indigo-600 focus:border-indigo-500 focus:outline-none dark:border-slate-600 dark:text-slate-400"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<span
|
||||
id="dynamic-audio-help"
|
||||
role="tooltip"
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-64 -translate-x-1/2 rounded-md bg-slate-900 px-3 py-2 text-xs font-normal text-white opacity-0 shadow-lg transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 dark:bg-slate-700"
|
||||
>
|
||||
Dynamic audio will be enabled for this block, while this block is playing.
|
||||
This will override any audio selections you have made on your device while
|
||||
this block is active.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -159,4 +159,35 @@ describe('BlockModal', () => {
|
||||
await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: 'Accept' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers an Enable Dynamic Audio checkbox with a help tooltip', async () => {
|
||||
mount();
|
||||
|
||||
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||
await expect.element(checkbox).toBeInTheDocument();
|
||||
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
|
||||
|
||||
await expect
|
||||
.element(page.getByText(/Dynamic audio will be enabled for this block/))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
|
||||
mount({ initialDynamicAudio: true });
|
||||
|
||||
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||
await expect.element(checkbox).toBeInTheDocument();
|
||||
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles the dynamic audio flag in the submitted payload', async () => {
|
||||
mount();
|
||||
|
||||
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||
await expect.element(checkbox).toBeInTheDocument();
|
||||
(checkbox.element() as HTMLInputElement).click();
|
||||
|
||||
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
|
||||
await expect.poll(() => hidden.value).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
id: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
dynamicAudio: boolean;
|
||||
proclamations: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export type BlockInput = {
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
proclamationIds: string[];
|
||||
/** When true the kiosk enables dynamic audio for the block's duration. */
|
||||
dynamicAudio?: boolean;
|
||||
};
|
||||
|
||||
export type ValidationResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('createBlock', () => {
|
||||
scheduleId: 's1',
|
||||
startMinutes: validInput.startMinutes,
|
||||
endMinutes: validInput.endMinutes,
|
||||
dynamicAudio: false,
|
||||
proclamations: {
|
||||
create: [
|
||||
{ proclamationId: 'p1', sortOrder: 0 },
|
||||
@@ -100,6 +101,19 @@ describe('createBlock', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the dynamic audio flag', async () => {
|
||||
blockFindMany.mockResolvedValue([]);
|
||||
blockCreate.mockResolvedValue({ id: 'new-block' });
|
||||
|
||||
await createBlock('u1', { ...validInput, dynamicAudio: true });
|
||||
|
||||
expect(blockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ dynamicAudio: true })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a fifth block without creating', async () => {
|
||||
blockFindMany.mockResolvedValue([
|
||||
{ id: 'a', startMinutes: 0, endMinutes: 60 },
|
||||
@@ -154,6 +168,7 @@ describe('updateBlock', () => {
|
||||
data: {
|
||||
startMinutes: validInput.startMinutes,
|
||||
endMinutes: validInput.endMinutes,
|
||||
dynamicAudio: false,
|
||||
proclamations: {
|
||||
deleteMany: {},
|
||||
create: [{ proclamationId: 'p1', sortOrder: 0 }]
|
||||
|
||||
@@ -67,6 +67,7 @@ export async function createBlock(userId: string, input: BlockInput): Promise<Bl
|
||||
scheduleId: schedule.id,
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
dynamicAudio: input.dynamicAudio === true,
|
||||
proclamations: {
|
||||
create: proclamationIds.map((proclamationId, index) => ({
|
||||
proclamationId,
|
||||
@@ -104,6 +105,7 @@ export async function updateBlock(
|
||||
data: {
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
dynamicAudio: input.dynamicAudio === true,
|
||||
proclamations: {
|
||||
deleteMany: {},
|
||||
create: proclamationIds.map((proclamationId, index) => ({
|
||||
|
||||
@@ -18,7 +18,8 @@ function parseInput(formData: FormData) {
|
||||
proclamationIds = [];
|
||||
}
|
||||
}
|
||||
return { startMinutes, endMinutes, proclamationIds };
|
||||
const dynamicAudio = formData.get('dynamicAudio') === 'true';
|
||||
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
@@ -48,6 +49,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
id: block.id,
|
||||
startMinutes: block.startMinutes,
|
||||
endMinutes: block.endMinutes,
|
||||
dynamicAudio: block.dynamicAudio,
|
||||
proclamations: block.proclamations.map((entry) => ({
|
||||
id: entry.proclamation.id,
|
||||
name: entry.proclamation.name,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
let initialStart = $state(9 * 60);
|
||||
let initialEnd = $state(10 * 60);
|
||||
let initialIds = $state<string[]>([]);
|
||||
let initialDynamicAudio = $state(false);
|
||||
let successMessage = $state('');
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
@@ -20,6 +21,7 @@
|
||||
initialStart = startMinutes;
|
||||
initialEnd = endMinutes;
|
||||
initialIds = [];
|
||||
initialDynamicAudio = false;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@
|
||||
id: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
dynamicAudio: boolean;
|
||||
proclamations: { id: string }[];
|
||||
}) {
|
||||
modalMode = 'edit';
|
||||
@@ -34,6 +37,7 @@
|
||||
initialStart = block.startMinutes;
|
||||
initialEnd = block.endMinutes;
|
||||
initialIds = block.proclamations.map((proclamation) => proclamation.id);
|
||||
initialDynamicAudio = block.dynamicAudio;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
@@ -85,6 +89,7 @@
|
||||
initialStartMinutes={initialStart}
|
||||
initialEndMinutes={initialEnd}
|
||||
initialProclamationIds={initialIds}
|
||||
{initialDynamicAudio}
|
||||
categories={data.categories}
|
||||
groups={data.groups}
|
||||
proclamations={data.proclamations}
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('schedule load', () => {
|
||||
id: 'b1',
|
||||
startMinutes: 360,
|
||||
endMinutes: 480,
|
||||
dynamicAudio: true,
|
||||
proclamations: [
|
||||
{
|
||||
proclamation: {
|
||||
@@ -78,9 +79,14 @@ describe('schedule load', () => {
|
||||
]);
|
||||
|
||||
const result = (await load({ locals } as never)) as {
|
||||
blocks: { id: string; proclamations: Record<string, unknown>[] }[];
|
||||
blocks: {
|
||||
id: string;
|
||||
dynamicAudio: boolean;
|
||||
proclamations: Record<string, unknown>[];
|
||||
}[];
|
||||
};
|
||||
|
||||
expect(result.blocks[0].dynamicAudio).toBe(true);
|
||||
expect(result.blocks[0].proclamations[0]).toEqual({
|
||||
id: 'p1',
|
||||
name: 'Peace',
|
||||
@@ -127,7 +133,8 @@ describe('schedule actions', () => {
|
||||
request: createFormRequest({
|
||||
startMinutes: '360',
|
||||
endMinutes: '480',
|
||||
proclamationIds: JSON.stringify(['p1', 'p2'])
|
||||
proclamationIds: JSON.stringify(['p1', 'p2']),
|
||||
dynamicAudio: 'true'
|
||||
}),
|
||||
locals
|
||||
} as never);
|
||||
@@ -136,10 +143,29 @@ describe('schedule actions', () => {
|
||||
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
|
||||
startMinutes: 360,
|
||||
endMinutes: 480,
|
||||
proclamationIds: ['p1', 'p2']
|
||||
proclamationIds: ['p1', 'p2'],
|
||||
dynamicAudio: true
|
||||
});
|
||||
});
|
||||
|
||||
it('createBlock defaults dynamic audio to false when the field is absent', async () => {
|
||||
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
|
||||
|
||||
await actions.createBlock!({
|
||||
request: createFormRequest({
|
||||
startMinutes: '360',
|
||||
endMinutes: '480',
|
||||
proclamationIds: JSON.stringify(['p1'])
|
||||
}),
|
||||
locals
|
||||
} as never);
|
||||
|
||||
expect(createBlockMock).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ dynamicAudio: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('deleteBlock returns not found without an id', async () => {
|
||||
const result = await actions.deleteBlock!({
|
||||
request: createFormRequest({}),
|
||||
|
||||
Reference in New Issue
Block a user