Skip to content

Commit

Permalink
add EntityAccordion.tsx
Browse files Browse the repository at this point in the history
  • Loading branch information
olmobrutall committed Aug 30, 2022
1 parent e6de729 commit 3ba48fa
Show file tree
Hide file tree
Showing 3 changed files with 231 additions and 0 deletions.
2 changes: 2 additions & 0 deletions Signum.React/Scripts/Lines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export { EntityList } from './Lines/EntityList'

export { EntityRepeater } from './Lines/EntityRepeater'

export { EntityAccordion } from './Lines/EntityAccordion'

export { EntityTabRepeater } from './Lines/EntityTabRepeater'

export { EntityStrip } from './Lines/EntityStrip'
Expand Down
211 changes: 211 additions & 0 deletions Signum.React/Scripts/Lines/EntityAccordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import * as React from 'react'
import { classes } from '../Globals'
import * as Navigator from '../Navigator'
import { TypeContext } from '../TypeContext'
import { ModifiableEntity, Lite, Entity, EntityControlMessage, getToString, isLite } from '../Signum.Entities'
import { EntityBaseController } from './EntityBase'
import { EntityListBaseController, EntityListBaseProps, DragConfig, MoveConfig } from './EntityListBase'
import { RenderEntity } from './RenderEntity'
import { newMListElement } from '../Signum.Entities';
import { tryGetTypeInfos, getTypeInfo } from '../Reflection';
import { useController } from './LineBase'
import { TypeBadge } from './AutoCompleteConfig'
import { Accordion } from 'react-bootstrap'
import { useForceUpdate } from '../Hooks'
import { AccordionEventKey } from 'react-bootstrap/esm/AccordionContext'

export interface EntityAccordionProps extends EntityListBaseProps {
createAsLink?: boolean | ((er: EntityAccordionController) => React.ReactElement<any>);
avoidFieldSet?: boolean;
createMessage?: string;
getTitle?: (ctx: TypeContext<any /*T*/>) => React.ReactChild;
itemExtraButtons?: (er: EntityListBaseController<EntityListBaseProps>, index: number) => React.ReactElement<any>;
initialSelectedIndex?: number | null;
selectedIndex?: number | null;
onSelectTab?: (newIndex: number | null) => void;
}

function isControlled(p: EntityAccordionProps) {

if ((p.selectedIndex !== undefined) != (p.onSelectTab !== undefined))
throw new Error("selectedIndex and onSelectTab should be set together");

return p.selectedIndex != null;
}

export class EntityAccordionController extends EntityListBaseController<EntityAccordionProps> {

selectedIndex!: number | null;
setSelectedIndex!: (index: number | null) => void;
initialIsControlled!: boolean;

init(p: EntityAccordionProps) {
super.init(p);

this.initialIsControlled = React.useMemo(() => isControlled(p), []);
const currentIsControlled = isControlled(p);
if (currentIsControlled != this.initialIsControlled)
throw new Error(`selectedIndex was isControlled=${this.initialIsControlled} but now is ${currentIsControlled}`);

if (!this.initialIsControlled) {
[this.selectedIndex, this.setSelectedIndex] = React.useState<number | null>(p.initialSelectedIndex ?? null);
} else {
this.selectedIndex = p.selectedIndex!;
this.setSelectedIndex = p.onSelectTab!;
}
}

getDefaultProps(p: EntityAccordionProps) {
super.getDefaultProps(p);
p.viewOnCreate = false;
p.createAsLink = true;
}

addElement(entityOrLite: Lite<Entity> | ModifiableEntity) {

if (isLite(entityOrLite) != (this.props.type!.isLite || false))
throw new Error("entityOrLite should be already converted");

const list = this.props.ctx.value!;
list.push(newMListElement(entityOrLite));
this.setSelectedIndex(list.length - 1);
this.setValue(list);
}
}


export const EntityAccordion = React.forwardRef(function EntityAccordion(props: EntityAccordionProps, ref: React.Ref<EntityAccordionController>) {
var c = useController(EntityAccordionController, props, ref);
var p = c.props;

if (c.isHidden)
return null;

let ctx = p.ctx;

if (p.avoidFieldSet == true)
return (
<div className={classes("sf-accordion-field sf-control-container", ctx.errorClassBorder)}
{...{ ...c.baseHtmlAttributes(), ...p.formGroupHtmlAttributes, ...ctx.errorAttributes() }}>
{renderButtons()}
{renderAccordion()}
</div>
);

return (
<fieldset className={classes("sf-accordion-field sf-control-container", ctx.errorClass)}
{...{ ...c.baseHtmlAttributes(), ...c.props.formGroupHtmlAttributes, ...ctx.errorAttributes() }}>
<legend>
<div>
<span>{p.labelText}</span>
{renderButtons()}
</div>
</legend>
{renderAccordion()}
</fieldset>
);


function renderButtons() {
const buttons = (
<span className="float-end">
{p.extraButtonsBefore && p.extraButtonsBefore(c)}
{p.createAsLink == false && c.renderCreateButton(false, p.createMessage)}
{c.renderFindButton(false)}
{p.extraButtonsAfter && p.extraButtonsAfter(c)}
</span>
);

return EntityBaseController.hasChildrens(buttons) ? buttons : undefined;
}

function handleSelectTab(eventKey: AccordionEventKey | null) {
var num = eventKey == null ? null: parseInt(eventKey as string);
c.setSelectedIndex(num);
}

function renderAccordion() {
const readOnly = ctx.readOnly;
const showType = tryGetTypeInfos(ctx.propertyRoute!.typeReference().name).length > 1;
return (
<Accordion className="sf-accordion-elements" activeKey={c.selectedIndex?.toString()} onSelect={handleSelectTab}>
{
c.getMListItemContext(ctx).map((mlec, i) => (
<EntityAccordionElement key={c.keyGenerator.getKey(mlec.value)}
onRemove={c.canRemove(mlec.value) && !readOnly ? e => c.handleRemoveElementClick(e, mlec.index!) : undefined}
ctx={mlec}
move={c.canMove(mlec.value) && p.moveMode == "MoveIcons" && !readOnly ? c.getMoveConfig(false, mlec.index!, "v") : undefined}
drag={c.canMove(mlec.value) && p.moveMode == "DragIcon" && !readOnly ? c.getDragConfig(mlec.index!, "v") : undefined}
itemExtraButtons={p.itemExtraButtons ? (() => p.itemExtraButtons!(c, mlec.index!)) : undefined}
getComponent={p.getComponent}
getViewPromise={p.getViewPromise}
getTitle={p.getTitle}
title={showType ? <TypeBadge entity={mlec.value} /> : undefined} />))
}
{
p.createAsLink && p.create && !readOnly &&
(typeof p.createAsLink == "function" ? p.createAsLink(c) :
<a href="#" title={ctx.titleLabels ? EntityControlMessage.Create.niceToString() : undefined}
className="sf-line-button sf-create"
onClick={c.handleCreateClick}>
{EntityBaseController.createIcon}&nbsp;{p.createMessage ?? EntityControlMessage.Create.niceToString()}
</a>)
}
</Accordion>
);
}
});


export interface EntityAccordionElementProps {
ctx: TypeContext<Lite<Entity> | ModifiableEntity>;
getComponent?: (ctx: TypeContext<ModifiableEntity>) => React.ReactElement<any>;
getViewPromise?: (entity: ModifiableEntity) => undefined | string | Navigator.ViewPromise<ModifiableEntity>;
getTitle?: (ctx: TypeContext<any /*T*/>) => React.ReactChild;
onRemove?: (event: React.MouseEvent<any>) => void;
move?: MoveConfig;
drag?: DragConfig;
title?: React.ReactElement<any>;
itemExtraButtons?: () => React.ReactElement<any>;
}

export function EntityAccordionElement({ ctx, getComponent, getViewPromise, onRemove, move, drag, itemExtraButtons, title, getTitle }: EntityAccordionElementProps)
{

const forceUpdate = useForceUpdate();

return (
<Accordion.Item className={classes(drag?.dropClass, "sf-accordion-element")} eventKey={ctx.index!.toString()}
onDragEnter={drag?.onDragOver}
onDragOver={drag?.onDragOver}
onDrop={drag?.onDrop}>

<Accordion.Header {...EntityListBaseController.entityHtmlAttributes(ctx.value)}>
<div className="d-flex align-items-center flex-grow-1">
{onRemove && <a href="#" className={classes("sf-line-button", "sf-remove")}
onClick={onRemove}
title={ctx.titleLabels ? EntityControlMessage.Remove.niceToString() : undefined}>
{EntityListBaseController.removeIcon}
</a>}
&nbsp;
{move?.renderMoveUp()}
{move?.renderMoveDown()}
{drag && <a href="#" className={classes("sf-line-button", "sf-move")}
draggable={true}
onDragStart={drag.onDragStart}
onDragEnd={drag.onDragEnd}
onKeyDown={drag.onKeyDown}
title={drag.title}>
{EntityListBaseController.moveIcon}
</a>}
{itemExtraButtons && itemExtraButtons()}
{'\xa0'}
{getTitle ? getTitle(ctx) : getToString(ctx.value)}
</div>
</Accordion.Header>
<Accordion.Body>
<RenderEntity ctx={ctx} getComponent={getComponent} getViewPromise={getViewPromise} onRefresh={forceUpdate} />
</Accordion.Body>
</Accordion.Item>
);
}
18 changes: 18 additions & 0 deletions Signum.React/Scripts/Lines/Lines.css
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,24 @@ ul.nav-tabs {
}


/*Accordion*/

.sf-accordion-element.drag-top >h2 >button {
box-shadow: inset #AAA 0px 3px 0px 0px !important;
}

.sf-accordion-element.drag-bottom > h2 > button {
box-shadow: inset #AAA 0px -3px 0px 0px !important;
}

/*.sf-accordion-element {
padding-bottom: 3px;
}
.sf-accordion-element:first-child {
padding-top: 3px;
}*/

/*buton colors*/
.sf-create:not(.disabled):hover {
color: #22BA00;
Expand Down

2 comments on commit 3ba48fa

@olmobrutall
Copy link
Collaborator Author

@olmobrutall olmobrutall commented on 3ba48fa Sep 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presenting EntityAccordion

There is a new component in Signum.React: EntityAccordion.

image

Creating this component is as simple as:

<EntityAccordion ctx={sc.subCtx(s => s.additionalRecipients)} />

It's good option for a MList of EmbeddedEntity / Entity with EntityKind.Part where you want a good one-line overview, but you need a block to actually modify / see details of the entity.

It's almost a replacement for an EntityRepeater, that typically takes too much vertical space.

It also shares some characteristics with EntityTabRepeater (both have a getTitle), but EntityAccordion is optimized for more place for the title and less place for the expanded content.

If you want that customize the title you can do it like this:

      <EntityAccordion ctx={sc.subCtx(s => s.additionalRecipients)}
        getTitle={(ctx: TypeContext<EmailRecipientEmbedded>) => <span>
          {ctx.value.kind && <strong className="me-1">{ctx.value.kind}:</strong>}
          {ctx.value.displayName && <span className="me-1">{ctx.value.displayName}</span>}
          {ctx.value.emailAddress && <span>{"<"}{ctx.value.emailAddress}{">"}</span>}
        </span>
        }/>

image

And to refresh the title after changes in the container components (without needing to Save):

export default function EmailRecipient(p : { ctx: TypeContext<EmailRecipientEmbedded> }){
  const sc = p.ctx.subCtx({ placeholderLabels: true, formGroupStyle: "SrOnly" });

  return (
    <div className="row">
      <div className="col-sm-1">
        <ValueLine ctx={sc.subCtx(c => c.kind)} onChange={e => p.ctx.frame?.frameComponent.forceUpdate()} />
      </div>
      <div className="col-sm-11">
        <EntityLine ctx={sc.subCtx(ea => ea.emailOwner)} />
      </div>
      <div className="col-sm-5 offset-sm-1">
        <ValueLine ctx={sc.subCtx(c => c.emailAddress)} valueHtmlAttributes={{ onBlur: e => p.ctx.frame?.frameComponent.forceUpdate() }} />
      </div>
      <div className="col-sm-6">
        <ValueLine ctx={sc.subCtx(c => c.displayName)} valueHtmlAttributes={{ onBlur: e => p.ctx.frame?.frameComponent.forceUpdate() }} />
      </div>
    </div>
  );
}

Like all the other components inheriting from EntityListBase, it has support for moving with drag-and-drop.

Now that EntityAccordion tries to replace EntityRepeater.... do we need a EntityCollapsableCard trying to replace EntityDetails?

@mehdy-karimpour
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great and very useful component, let`s use it 🥇

Please sign in to comment.