Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@blade47/react-scheduler",
"private": false,
"version": "1.1.0",
"version": "1.1.1",
"description": "React scheduler component based on Material-UI & Dayjs",
"type": "module",
"main": "./dist/index.js",
Expand Down
44 changes: 44 additions & 0 deletions src/components/common/ResourceHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import type { Scheduler } from '@/types.ts';

import { render } from '@testing-library/react';
import { expect, describe, it } from 'vitest';

import { StoreProvider } from '../../store/provider.tsx';
import { ResourceHeader } from './ResourceHeader.tsx';

// Regression for a crash that escaped the library entirely: ResourceHeader read
// `resource[resourceFields.textField]` and called `.charAt(0)` on it unguarded. A resource missing
// that field yields undefined, and a render-phase TypeError is NOT contained — React unmounts the
// tree and the CONSUMER's error boundary handles it. Downstream that meant a full page reload for
// a calendar with no resources configured, because Day's placeholder was keyed {id,text} while the
// consumer had remapped resourceFields. Both halves are fixed; this pins the resilient half, since
// a library must not take the host application down over one malformed resource.
function renderHeader(initial: Partial<Scheduler>, resource: Record<string, unknown>) {
return render(
<StoreProvider initial={initial as Scheduler}>
<ResourceHeader resource={resource as never} />
</StoreProvider>
);
}

describe('ResourceHeader', () => {
const fields = { idField: 'resourceid', textField: 'name' };

it('renders without throwing when the resource has no value for the configured text field', () => {
expect(() =>
renderHeader({ resourceFields: fields } as Partial<Scheduler>, { resourceid: 'default' })
).not.toThrow();
});

it('still renders the text and its avatar initial when the field IS present', () => {
// Guards the null-coalescing against over-reach: silencing the crash must not silence the name.
const { getByText } = renderHeader({ resourceFields: fields } as Partial<Scheduler>, {
resourceid: 'room-1',
name: 'Aula Magna',
});

expect(getByText('Aula Magna')).toBeTruthy();
expect(getByText('A')).toBeTruthy();
});
});
7 changes: 6 additions & 1 deletion src/components/common/ResourceHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ export const ResourceHeader = ({ resource }: Props) => {

const theme = useTheme();

// `?? ''` because the declared `text: string` is a claim about the CONSUMER's data, not something
// this type can enforce: a resource missing the configured textField yields undefined here, and
// `text` is dereferenced unguarded below (`.charAt(0)`). A render-phase throw does not degrade —
// React unmounts the tree and the consumer's error boundary takes over, so one misconfigured
// resource took down the whole page. An empty header is the correct failure mode for a library.
const getResourceFields = (): LocalResourceFields => ({
text: resource[resourceFields.textField],
text: resource[resourceFields.textField] ?? '',
subtext: resource[resourceFields.subTextField || ''],
avatar: resource[resourceFields.avatarField || ''],
color: resource[resourceFields.colorField || ''],
Expand Down
12 changes: 8 additions & 4 deletions src/components/scheduler/Scheduler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,15 @@ export const SchedulerComponent = forwardRef<SchedulerRef>((_, ref) => {
// see DayTable), so it must never trigger the side-by-side row layout that week/month's
// per-resource cards use. Counting day as >1 flipped the container to flex-row and broke
// the header/body stacking.
resourceCount={
view !== 'day' && resourceViewMode === 'default' ? resources.length : 1
}
resourceCount={view !== 'day' && resourceViewMode === 'default' ? resources.length : 1}
bounded={Boolean(boundedHeight)}
tabMode={resourceViewMode === 'tabs'}
// Only when tabs are actually RENDERED, not merely requested: Week short-circuits
// WithResources when there are no resources, so with `resourceViewMode: 'tabs'` and an
// empty list the tab card never exists — and the bounded+tabMode rule, which targets
// `& > div:first-of-type` believing it to be that card, hit the sticky header grid instead
// and stretched it to minHeight 100%, pushing the hour rows a full viewport out of sight.
// Falling through to the resourceCount<=1 branch is exactly right for an empty calendar.
tabMode={resourceViewMode === 'tabs' && resources.length > 0}
sx={{
overflowX:
view !== 'day' && resourceViewMode === 'default' && resources.length > 1
Expand Down
13 changes: 10 additions & 3 deletions src/views/day/Day.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useDayEvents } from '@/views/day/hooks/useDayEvents.ts';
import { DayGrid } from '@/views/day/components/DayGrid.tsx';

export const Day = () => {
const { selectedDate, resources, agenda, day, timeZone } = useStore();
const { selectedDate, resources, agenda, day, timeZone, resourceFields } = useStore();

const selectedDayjs = dayjs(selectedDate);

Expand All @@ -26,8 +26,15 @@ export const Day = () => {

// Handle the case where there are no resources
if (resources.length === 0) {
// Create a default resource to still show the day view
const defaultResource = [{ id: 'default', text: 'Default' }];
// Create a default resource to still show the day view, keyed by the CONSUMER's resourceFields
// rather than literal id/text. Every reader of a resource looks its fields up through
// resourceFields, so a placeholder with hardcoded keys is invisible to all of them as soon as a
// consumer remaps the fields — ResourceHeader then read `resource[textField]` as undefined and
// threw on `.charAt(0)`, which React escalates out of the library into the consumer's error
// boundary. Reported downstream as "no rooms configured makes the day view reload the page".
const defaultResource = [
{ [resourceFields.idField]: 'default', [resourceFields.textField]: 'Default' },
];

return agenda ? (
<AgendaView view="day" events={events} />
Expand Down