Skip to content

Commit

Permalink
Initial support for appearances (and body classes)
Browse files Browse the repository at this point in the history
Automated testing is pending. Some manual validation has been done to verify that this likely works as intended. The intent is to make this first pass available for client iteration as quickly as possible.

While it’s possible to include unit tests for `TokenListParser`, it seems more likely we’ll want to add integration tests in the `scenario` package. Given there’s a ton of unaddressed feedback in #110, it seems most prudent to get this into draft first, and bring in integration tests once that  lands.
  • Loading branch information
eyelidlessness committed May 30, 2024
1 parent 47f2a98 commit fc776a9
Show file tree
Hide file tree
Showing 33 changed files with 507 additions and 34 deletions.
27 changes: 27 additions & 0 deletions packages/common/types/string/PartiallyKnownString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Produces a `string` type while preserving autocomplete/autosuggest
* functionality for a known string (union).
*
* @see {@link https://www.totaltypescript.com/tips/create-autocomplete-helper-which-allows-for-arbitrary-values}
*
* @example
* ```ts
* let foo: PartiallyKnownString<'a' | 'b' | 'zed'>;
*
* // Each of these will be suggested by a TypeScript-supporting editor:
* foo = 'a';
* foo = 'b';
* foo = 'zed';
*
* // ... but any string is valid:
* foo = 'lmnop';
* ```
*/
// prettier-ignore
export type PartiallyKnownString<Known extends string> =
[string] extends [Known]
? string
: (
| Known
| (string & { /* Type hack! */ })
);
34 changes: 32 additions & 2 deletions packages/xforms-engine/src/body/BodyDefinition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { XFormDefinition } from '../XFormDefinition.ts';
import { DependencyContext } from '../expression/DependencyContext.ts';
import type { ParsedTokenList } from '../lib/TokenListParser.ts';
import { TokenListParser } from '../lib/TokenListParser.ts';
import { RepeatElementDefinition } from './RepeatElementDefinition.ts';
import { UnsupportedBodyElementDefinition } from './UnsupportedBodyElementDefinition.ts';
import { ControlDefinition } from './control/ControlDefinition.ts';
Expand All @@ -15,14 +17,18 @@ export interface BodyElementParentContext {
readonly element: Element;
}

// prettier-ignore
export type ControlElementDefinition =
| AnySelectDefinition
| InputDefinition;

type SupportedBodyElementDefinition =
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
| RepeatElementDefinition
| LogicalGroupDefinition
| PresentationGroupDefinition
| StructuralGroupDefinition
| InputDefinition
| AnySelectDefinition;
| ControlElementDefinition;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type BodyElementDefinitionConstructor = new (...args: any[]) => SupportedBodyElementDefinition;
Expand Down Expand Up @@ -135,6 +141,10 @@ class BodyElementMap extends Map<BodyElementReference, AnyBodyElementDefinition>
}
}

const bodyClassParser = new TokenListParser(['pages' /*, 'theme-grid' */]);

export type BodyClassList = ParsedTokenList<typeof bodyClassParser>;

export class BodyDefinition extends DependencyContext {
static getChildElementDefinitions(
form: XFormDefinition,
Expand All @@ -156,6 +166,25 @@ export class BodyDefinition extends DependencyContext {
}

readonly element: Element;

/**
* @todo this class is already an oddity in that it's **like** an element
* definition, but it isn't one itself. Adding this property here emphasizes
* that awkwardness. It also extends the applicable scope where instances of
* this class are accessed. While it's still ephemeral, it's anticipated that
* this extension might cause some disomfort. If so, the most plausible
* alternative is an additional refactor to:
*
* 1. Introduce a `BodyElementDefinition` sublass for `<h:body>`.
* 2. Disambiguate the respective names of those, in some reasonable way.
* 3. Add a layer of indirection between this class and that new body element
* definition's class.
* 4. At that point, we may as well prioritize the little bit of grunt work to
* pass the `BodyDefinition` instance by reference rather than assigning it
* to anything.
*/
readonly classes: BodyClassList;

readonly elements: readonly AnyBodyElementDefinition[];

protected readonly elementsByReference: BodyElementMap;
Expand All @@ -171,6 +200,7 @@ export class BodyDefinition extends DependencyContext {

this.reference = form.rootReference;
this.element = element;
this.classes = bodyClassParser.parseFrom(element, 'class');
this.elements = BodyDefinition.getChildElementDefinitions(form, this, element);
this.elementsByReference = new BodyElementMap(this.elements);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/xforms-engine/src/body/RepeatElementDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { XFormDefinition } from '../XFormDefinition.ts';
import type { BodyElementDefinitionArray, BodyElementParentContext } from './BodyDefinition.ts';
import { BodyDefinition } from './BodyDefinition.ts';
import { BodyElementDefinition } from './BodyElementDefinition.ts';
import type { StructureElementAppearanceDefinition } from './appearance/structureElementAppearanceParser.ts';
import { structureElementAppearanceParser } from './appearance/structureElementAppearanceParser.ts';
import { LabelDefinition } from './text/LabelDefinition.ts';

export class RepeatElementDefinition extends BodyElementDefinition<'repeat'> {
Expand All @@ -13,6 +15,7 @@ export class RepeatElementDefinition extends BodyElementDefinition<'repeat'> {
override readonly category = 'structure';
readonly type = 'repeat';
override readonly reference: string;
readonly appearances: StructureElementAppearanceDefinition;
override readonly label: LabelDefinition | null;

// TODO: this will fall into the growing category of non-`BindExpression`
Expand All @@ -35,6 +38,7 @@ export class RepeatElementDefinition extends BodyElementDefinition<'repeat'> {
}

this.reference = reference;
this.appearances = structureElementAppearanceParser.parseFrom(element, 'appearance');
this.countExpression = element.getAttributeNS(JAVAROSA_NAMESPACE_URI, 'count');

const childElements = Array.from(element.children).filter((childElement) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { TokenListParser, type ParsedTokenList } from '../../lib/TokenListParser.ts';

export const inputAppearanceParser = new TokenListParser([
'multiline',
'numbers',
'url',
'thousand-sep',

// date (TODO: data types)
'no-calendar',
'month-year',
'year',
// date > calendars
'ethiopian',
'coptic',
'islamic',
'bikram-sambat',
'myanmar',
'persian',

// geo (TODO: data types)
'placement-map',
'maps',

// image/media (TODO: move to eventual `<upload>`?)
'hidden-answer',
'annotate',
'draw',
'signature',
'new-front',
'new',
'front',

// *?
'printer', // Note: actual usage uses `printer:...` (like `ex:...`).
'masked',
]);

export type InputAppearanceDefinition = ParsedTokenList<typeof inputAppearanceParser>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { TokenListParser, type ParsedTokenList } from '../../lib/TokenListParser.ts';

export const selectAppearanceParser = new TokenListParser(
[
// From XLSForm Docs:
'compact',
'horizontal',
'horizontal-compact',
'label',
'list-nolabel',
'minimal',

// From Collect `Appearances.kt`:
'columns',
'columns-1',
'columns-2',
'columns-3',
'columns-4',
'columns-5',
// Note: Collect supports arbitrary columns-n. Technically we do too (we parse
// out any appearance, not just those we know about). But we'll only include
// types/defaults up to 5.
'columns-pack',
'autocomplete',

// TODO: these are `<select1>` only
'likert',
'quick',
'quickcompact',
'map',
// "quick map"
],
{
aliases: [{ fromAlias: 'search', toCanonical: 'autocomplete' }],
}
);

export type SelectAppearanceDefinition = ParsedTokenList<typeof selectAppearanceParser>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TokenListParser, type ParsedTokenList } from '../../lib/TokenListParser.ts';

export const structureElementAppearanceParser = new TokenListParser(['field-list', 'table-list']);

export type StructureElementAppearanceDefinition = ParsedTokenList<
typeof structureElementAppearanceParser
>;
4 changes: 4 additions & 0 deletions packages/xforms-engine/src/body/control/ControlDefinition.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { XFormDefinition } from '../../XFormDefinition.ts';
import type { ParsedTokenList } from '../../lib/TokenListParser.ts';
import type { BodyElementParentContext } from '../BodyDefinition.ts';
import { BodyElementDefinition } from '../BodyElementDefinition.ts';
import { HintDefinition } from '../text/HintDefinition.ts';
Expand All @@ -23,6 +24,9 @@ export abstract class ControlDefinition<
override readonly label: LabelDefinition | null;
override readonly hint: HintDefinition | null;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
abstract readonly appearances: ParsedTokenList<any>;

constructor(form: XFormDefinition, parent: BodyElementParentContext, element: Element) {
super(form, parent, element);

Expand Down
13 changes: 13 additions & 0 deletions packages/xforms-engine/src/body/control/InputDefinition.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import type { XFormDefinition } from '../../XFormDefinition.ts';
import type { BodyElementParentContext } from '../BodyDefinition.ts';
import {
inputAppearanceParser,
type InputAppearanceDefinition,
} from '../appearance/inputAppearanceParser.ts';
import { ControlDefinition } from './ControlDefinition.ts';

export class InputDefinition extends ControlDefinition<'input'> {
Expand All @@ -6,4 +12,11 @@ export class InputDefinition extends ControlDefinition<'input'> {
}

readonly type = 'input';
readonly appearances: InputAppearanceDefinition;

constructor(form: XFormDefinition, parent: BodyElementParentContext, element: Element) {
super(form, parent, element);

this.appearances = inputAppearanceParser.parseFrom(element, 'appearance');
}
}
19 changes: 14 additions & 5 deletions packages/xforms-engine/src/body/control/select/SelectDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@ import type { LocalNamedElement } from '@getodk/common/types/dom.ts';
import type { XFormDefinition } from '../../../XFormDefinition.ts';
import { getItemElements, getItemsetElement } from '../../../lib/dom/query.ts';
import type { AnyBodyElementDefinition, BodyElementParentContext } from '../../BodyDefinition.ts';
import type { SelectAppearanceDefinition } from '../../appearance/selectAppearanceParser.ts';
import { selectAppearanceParser } from '../../appearance/selectAppearanceParser.ts';
import { ControlDefinition } from '../ControlDefinition.ts';
import { ItemDefinition } from './ItemDefinition.ts';
import { ItemsetDefinition } from './ItemsetDefinition.ts';

// TODO: `<trigger>` is *almost* reasonable to support here too. The main
// hesitation is that its single, implicit "item" does not have a distinct
// <label>, and presumably has different UX **and translation** considerations.
const selectLocalNames = new Set(['rank', 'select', 'select1'] as const);
/**
* @todo We were previously a bit overzealous about introducing `<rank>` support
* here. It'll likely still fit, but we should approach it with more intention.
*
* @todo `<trigger>` is *almost* reasonable to support here too. The main
* hesitation is that its single, implicit "item" does not have a distinct
* <label>, and presumably has different UX **and translation** considerations.
*/
const selectLocalNames = new Set([/* 'rank', */ 'select', 'select1'] as const);

export type SelectType = CollectionValues<typeof selectLocalNames>;

Expand All @@ -34,6 +41,7 @@ export class SelectDefinition<Type extends SelectType> extends ControlDefinition

override readonly type: Type;
override readonly element: SelectElement;
readonly appearances: SelectAppearanceDefinition;

readonly itemset: ItemsetDefinition | null;
readonly items: readonly ItemDefinition[];
Expand All @@ -45,8 +53,9 @@ export class SelectDefinition<Type extends SelectType> extends ControlDefinition

super(form, parent, element);

this.element = element;
this.type = element.localName as Type;
this.element = element;
this.appearances = selectAppearanceParser.parseFrom(element, 'appearance');

const itemsetElement = getItemsetElement(element);
const itemElements = getItemElements(element);
Expand Down
4 changes: 4 additions & 0 deletions packages/xforms-engine/src/body/group/BaseGroupDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
type BodyElementParentContext,
} from '../BodyDefinition.ts';
import { BodyElementDefinition } from '../BodyElementDefinition.ts';
import type { StructureElementAppearanceDefinition } from '../appearance/structureElementAppearanceParser.ts';
import { structureElementAppearanceParser } from '../appearance/structureElementAppearanceParser.ts';
import { LabelDefinition } from '../text/LabelDefinition.ts';

/**
Expand Down Expand Up @@ -67,6 +69,7 @@ export abstract class BaseGroupDefinition<
readonly children: BodyElementDefinitionArray;

override readonly reference: string | null;
readonly appearances: StructureElementAppearanceDefinition;
override readonly label: LabelDefinition | null;

constructor(
Expand All @@ -79,6 +82,7 @@ export abstract class BaseGroupDefinition<

this.children = children ?? this.getChildren(element);
this.reference = element.getAttribute('ref');
this.appearances = structureElementAppearanceParser.parseFrom(element, 'appearance');
this.label = LabelDefinition.forGroup(form, this);
}

Expand Down
6 changes: 2 additions & 4 deletions packages/xforms-engine/src/body/text/TextElementDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import { type AnyDependentExpression } from '../../expression/DependentExpressio
import type { AnyGroupElementDefinition } from '../BodyDefinition.ts';
import { BodyElementDefinition } from '../BodyElementDefinition.ts';
import type { RepeatElementDefinition } from '../RepeatElementDefinition.ts';
import type { InputDefinition } from '../control/InputDefinition.ts';
import type { AnyControlDefinition } from '../control/ControlDefinition.ts';
import type { ItemDefinition } from '../control/select/ItemDefinition.ts';
import type { ItemsetDefinition } from '../control/select/ItemsetDefinition.ts';
import type { AnySelectDefinition } from '../control/select/SelectDefinition.ts';
import { TextElementOutputPart } from './TextElementOutputPart.ts';
import { TextElementReferencePart } from './TextElementReferencePart.ts';
import { TextElementStaticPart } from './TextElementStaticPart.ts';
Expand All @@ -19,9 +18,8 @@ export interface TextElement extends Element {
}

export type TextElementOwner =
| AnyControlDefinition
| AnyGroupElementDefinition
| AnySelectDefinition
| InputDefinition
| ItemDefinition
| ItemsetDefinition
| RepeatElementDefinition;
Expand Down
10 changes: 9 additions & 1 deletion packages/xforms-engine/src/client/BaseNode.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { TokenListParser } from '../lib/TokenListParser.ts';
import type { AnyNodeDefinition } from '../model/NodeDefinition.ts';
import type { InstanceNodeType } from './node-types.js';
import type { NodeAppearances } from './NodeAppearances.ts';
import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts';
import type { TextRange } from './TextRange.ts';
import type { InstanceNodeType } from './node-types.ts';

export interface BaseNodeState {
/**
Expand Down Expand Up @@ -126,6 +128,12 @@ export interface BaseNode {
*/
readonly nodeId: FormNodeID;

/**
* @see {@link TokenListParser} for details.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly appearances: NodeAppearances<any> | null;

/**
* Each node has a definition which specifies aspects of the node defined in
* the form. These aspects include (but are not limited to) the node's data
Expand Down
4 changes: 4 additions & 0 deletions packages/xforms-engine/src/client/GroupNode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AnyGroupElementDefinition } from '../body/BodyDefinition.ts';
import type { SubtreeDefinition } from '../model/SubtreeDefinition.ts';
import type { BaseNode, BaseNodeState } from './BaseNode.ts';
import type { NodeAppearances } from './NodeAppearances.ts';
import type { RootNode } from './RootNode.ts';
import type { GeneralChildNode, GeneralParentNode } from './hierarchy.ts';

Expand All @@ -17,6 +18,8 @@ export interface GroupDefinition extends SubtreeDefinition {
readonly bodyElement: AnyGroupElementDefinition;
}

export type GroupNodeAppearances = NodeAppearances<GroupDefinition>;

/**
* A node corresponding to an XForms `<group>`.
*/
Expand All @@ -26,6 +29,7 @@ export interface GroupDefinition extends SubtreeDefinition {
// for context.
export interface GroupNode extends BaseNode {
readonly nodeType: 'group';
readonly appearances: GroupNodeAppearances;
readonly definition: GroupDefinition;
readonly root: RootNode;
readonly parent: GeneralParentNode;
Expand Down
Loading

0 comments on commit fc776a9

Please sign in to comment.