Add Enable Dynamic Audio option to schedule blocks
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
initialStartMinutes = 9 * 60,
|
initialStartMinutes = 9 * 60,
|
||||||
initialEndMinutes = 10 * 60,
|
initialEndMinutes = 10 * 60,
|
||||||
initialProclamationIds = [],
|
initialProclamationIds = [],
|
||||||
|
initialDynamicAudio = false,
|
||||||
categories = [],
|
categories = [],
|
||||||
groups = [],
|
groups = [],
|
||||||
proclamations = [],
|
proclamations = [],
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
initialStartMinutes?: number;
|
initialStartMinutes?: number;
|
||||||
initialEndMinutes?: number;
|
initialEndMinutes?: number;
|
||||||
initialProclamationIds?: string[];
|
initialProclamationIds?: string[];
|
||||||
|
initialDynamicAudio?: boolean;
|
||||||
categories?: BrowserCategory[];
|
categories?: BrowserCategory[];
|
||||||
groups?: BrowserGroup[];
|
groups?: BrowserGroup[];
|
||||||
proclamations?: BrowserProclamation[];
|
proclamations?: BrowserProclamation[];
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
let endMinute = $state('0');
|
let endMinute = $state('0');
|
||||||
let endPeriod = $state<'AM' | 'PM'>('AM');
|
let endPeriod = $state<'AM' | 'PM'>('AM');
|
||||||
let selectedIds = $state<string[]>([]);
|
let selectedIds = $state<string[]>([]);
|
||||||
|
let dynamicAudio = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
|
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
|
||||||
@@ -98,6 +101,7 @@
|
|||||||
endMinute = String(end.minute);
|
endMinute = String(end.minute);
|
||||||
endPeriod = end.period;
|
endPeriod = end.period;
|
||||||
selectedIds = [...initialProclamationIds];
|
selectedIds = [...initialProclamationIds];
|
||||||
|
dynamicAudio = initialDynamicAudio;
|
||||||
error = '';
|
error = '';
|
||||||
step = 'details';
|
step = 'details';
|
||||||
});
|
});
|
||||||
@@ -209,6 +213,7 @@
|
|||||||
<input type="hidden" name="startMinutes" value={startMinutes} />
|
<input type="hidden" name="startMinutes" value={startMinutes} />
|
||||||
<input type="hidden" name="endMinutes" value={endMinutes} />
|
<input type="hidden" name="endMinutes" value={endMinutes} />
|
||||||
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
|
<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">
|
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -313,6 +318,39 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<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: 'Create Block' })).toBeInTheDocument();
|
||||||
await expect.element(page.getByRole('button', { name: 'Accept' })).not.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;
|
id: string;
|
||||||
startMinutes: number;
|
startMinutes: number;
|
||||||
endMinutes: number;
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
proclamations: { id: string; name: string }[];
|
proclamations: { id: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type BlockInput = {
|
|||||||
startMinutes: number;
|
startMinutes: number;
|
||||||
endMinutes: number;
|
endMinutes: number;
|
||||||
proclamationIds: string[];
|
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 };
|
export type ValidationResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ describe('createBlock', () => {
|
|||||||
scheduleId: 's1',
|
scheduleId: 's1',
|
||||||
startMinutes: validInput.startMinutes,
|
startMinutes: validInput.startMinutes,
|
||||||
endMinutes: validInput.endMinutes,
|
endMinutes: validInput.endMinutes,
|
||||||
|
dynamicAudio: false,
|
||||||
proclamations: {
|
proclamations: {
|
||||||
create: [
|
create: [
|
||||||
{ proclamationId: 'p1', sortOrder: 0 },
|
{ 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 () => {
|
it('rejects a fifth block without creating', async () => {
|
||||||
blockFindMany.mockResolvedValue([
|
blockFindMany.mockResolvedValue([
|
||||||
{ id: 'a', startMinutes: 0, endMinutes: 60 },
|
{ id: 'a', startMinutes: 0, endMinutes: 60 },
|
||||||
@@ -154,6 +168,7 @@ describe('updateBlock', () => {
|
|||||||
data: {
|
data: {
|
||||||
startMinutes: validInput.startMinutes,
|
startMinutes: validInput.startMinutes,
|
||||||
endMinutes: validInput.endMinutes,
|
endMinutes: validInput.endMinutes,
|
||||||
|
dynamicAudio: false,
|
||||||
proclamations: {
|
proclamations: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
create: [{ proclamationId: 'p1', sortOrder: 0 }]
|
create: [{ proclamationId: 'p1', sortOrder: 0 }]
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export async function createBlock(userId: string, input: BlockInput): Promise<Bl
|
|||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
startMinutes: input.startMinutes,
|
startMinutes: input.startMinutes,
|
||||||
endMinutes: input.endMinutes,
|
endMinutes: input.endMinutes,
|
||||||
|
dynamicAudio: input.dynamicAudio === true,
|
||||||
proclamations: {
|
proclamations: {
|
||||||
create: proclamationIds.map((proclamationId, index) => ({
|
create: proclamationIds.map((proclamationId, index) => ({
|
||||||
proclamationId,
|
proclamationId,
|
||||||
@@ -104,6 +105,7 @@ export async function updateBlock(
|
|||||||
data: {
|
data: {
|
||||||
startMinutes: input.startMinutes,
|
startMinutes: input.startMinutes,
|
||||||
endMinutes: input.endMinutes,
|
endMinutes: input.endMinutes,
|
||||||
|
dynamicAudio: input.dynamicAudio === true,
|
||||||
proclamations: {
|
proclamations: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
create: proclamationIds.map((proclamationId, index) => ({
|
create: proclamationIds.map((proclamationId, index) => ({
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ function parseInput(formData: FormData) {
|
|||||||
proclamationIds = [];
|
proclamationIds = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { startMinutes, endMinutes, proclamationIds };
|
const dynamicAudio = formData.get('dynamicAudio') === 'true';
|
||||||
|
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
@@ -48,6 +49,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
id: block.id,
|
id: block.id,
|
||||||
startMinutes: block.startMinutes,
|
startMinutes: block.startMinutes,
|
||||||
endMinutes: block.endMinutes,
|
endMinutes: block.endMinutes,
|
||||||
|
dynamicAudio: block.dynamicAudio,
|
||||||
proclamations: block.proclamations.map((entry) => ({
|
proclamations: block.proclamations.map((entry) => ({
|
||||||
id: entry.proclamation.id,
|
id: entry.proclamation.id,
|
||||||
name: entry.proclamation.name,
|
name: entry.proclamation.name,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
let initialStart = $state(9 * 60);
|
let initialStart = $state(9 * 60);
|
||||||
let initialEnd = $state(10 * 60);
|
let initialEnd = $state(10 * 60);
|
||||||
let initialIds = $state<string[]>([]);
|
let initialIds = $state<string[]>([]);
|
||||||
|
let initialDynamicAudio = $state(false);
|
||||||
let successMessage = $state('');
|
let successMessage = $state('');
|
||||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
initialStart = startMinutes;
|
initialStart = startMinutes;
|
||||||
initialEnd = endMinutes;
|
initialEnd = endMinutes;
|
||||||
initialIds = [];
|
initialIds = [];
|
||||||
|
initialDynamicAudio = false;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
id: string;
|
id: string;
|
||||||
startMinutes: number;
|
startMinutes: number;
|
||||||
endMinutes: number;
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
proclamations: { id: string }[];
|
proclamations: { id: string }[];
|
||||||
}) {
|
}) {
|
||||||
modalMode = 'edit';
|
modalMode = 'edit';
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
initialStart = block.startMinutes;
|
initialStart = block.startMinutes;
|
||||||
initialEnd = block.endMinutes;
|
initialEnd = block.endMinutes;
|
||||||
initialIds = block.proclamations.map((proclamation) => proclamation.id);
|
initialIds = block.proclamations.map((proclamation) => proclamation.id);
|
||||||
|
initialDynamicAudio = block.dynamicAudio;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@
|
|||||||
initialStartMinutes={initialStart}
|
initialStartMinutes={initialStart}
|
||||||
initialEndMinutes={initialEnd}
|
initialEndMinutes={initialEnd}
|
||||||
initialProclamationIds={initialIds}
|
initialProclamationIds={initialIds}
|
||||||
|
{initialDynamicAudio}
|
||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
groups={data.groups}
|
groups={data.groups}
|
||||||
proclamations={data.proclamations}
|
proclamations={data.proclamations}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ describe('schedule load', () => {
|
|||||||
id: 'b1',
|
id: 'b1',
|
||||||
startMinutes: 360,
|
startMinutes: 360,
|
||||||
endMinutes: 480,
|
endMinutes: 480,
|
||||||
|
dynamicAudio: true,
|
||||||
proclamations: [
|
proclamations: [
|
||||||
{
|
{
|
||||||
proclamation: {
|
proclamation: {
|
||||||
@@ -78,9 +79,14 @@ describe('schedule load', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const result = (await load({ locals } as never)) as {
|
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({
|
expect(result.blocks[0].proclamations[0]).toEqual({
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
name: 'Peace',
|
name: 'Peace',
|
||||||
@@ -127,7 +133,8 @@ describe('schedule actions', () => {
|
|||||||
request: createFormRequest({
|
request: createFormRequest({
|
||||||
startMinutes: '360',
|
startMinutes: '360',
|
||||||
endMinutes: '480',
|
endMinutes: '480',
|
||||||
proclamationIds: JSON.stringify(['p1', 'p2'])
|
proclamationIds: JSON.stringify(['p1', 'p2']),
|
||||||
|
dynamicAudio: 'true'
|
||||||
}),
|
}),
|
||||||
locals
|
locals
|
||||||
} as never);
|
} as never);
|
||||||
@@ -136,10 +143,29 @@ describe('schedule actions', () => {
|
|||||||
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
|
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
|
||||||
startMinutes: 360,
|
startMinutes: 360,
|
||||||
endMinutes: 480,
|
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 () => {
|
it('deleteBlock returns not found without an id', async () => {
|
||||||
const result = await actions.deleteBlock!({
|
const result = await actions.deleteBlock!({
|
||||||
request: createFormRequest({}),
|
request: createFormRequest({}),
|
||||||
|
|||||||
Reference in New Issue
Block a user