diff --git a/packages/-ember-caluma/app/controllers/demo/form-rendering.js b/packages/-ember-caluma/app/controllers/demo/form-rendering.js index f1658147f..56996f489 100644 --- a/packages/-ember-caluma/app/controllers/demo/form-rendering.js +++ b/packages/-ember-caluma/app/controllers/demo/form-rendering.js @@ -7,6 +7,8 @@ export default class DemoFormRenderingController extends Controller { queryParams = ["displayedForm"]; + compareTo = new Date(Date.parse("14 May 2024 12:38:10 GMT")); + @action actionButtonOnSuccess() { this.notification.success("Successfully submitted the form!"); } diff --git a/packages/-ember-caluma/app/routes/demo/form-rendering.js b/packages/-ember-caluma/app/routes/demo/form-rendering.js index f108b3cbc..ce3766e70 100644 --- a/packages/-ember-caluma/app/routes/demo/form-rendering.js +++ b/packages/-ember-caluma/app/routes/demo/form-rendering.js @@ -13,7 +13,7 @@ export default class DemoFormRenderingRoute extends Route { fetchPolicy: "network-only", query: gql` query { - allDocuments(filter: [{ form: "formular-1" }]) { + allDocuments(filter: [{ form: "demo-formular-1" }]) { edges { node { id diff --git a/packages/-ember-caluma/app/templates/demo/form-rendering.hbs b/packages/-ember-caluma/app/templates/demo/form-rendering.hbs index 35e8f3c74..d6a03e54f 100644 --- a/packages/-ember-caluma/app/templates/demo/form-rendering.hbs +++ b/packages/-ember-caluma/app/templates/demo/form-rendering.hbs @@ -1,6 +1,7 @@ {{#if @model}} {{/if}} \ No newline at end of file diff --git a/packages/core/addon/-private/possible-types.js b/packages/core/addon/-private/possible-types.js index 46c6a8c3a..92bf20b86 100644 --- a/packages/core/addon/-private/possible-types.js +++ b/packages/core/addon/-private/possible-types.js @@ -53,6 +53,15 @@ export default { "AnalyticsTable", "AvailableField", "AnalyticsField", + "HistoricalStringAnswer", + "HistoricalListAnswer", + "HistoricalIntegerAnswer", + "HistoricalFloatAnswer", + "HistoricalDateAnswer", + "HistoricalTableAnswer", + "HistoricalDocument", + "HistoricalFilesAnswer", + "HistoricalFile", "DynamicOption", ], Answer: [ @@ -66,4 +75,13 @@ export default { ], Task: ["SimpleTask", "CompleteWorkflowFormTask", "CompleteTaskFormTask"], DynamicQuestion: ["DynamicChoiceQuestion", "DynamicMultipleChoiceQuestion"], + HistoricalAnswer: [ + "HistoricalStringAnswer", + "HistoricalListAnswer", + "HistoricalIntegerAnswer", + "HistoricalFloatAnswer", + "HistoricalDateAnswer", + "HistoricalTableAnswer", + "HistoricalFilesAnswer", + ], }; diff --git a/packages/form/addon/components/cf-content.js b/packages/form/addon/components/cf-content.js index bbcc1dd62..946992e4b 100644 --- a/packages/form/addon/components/cf-content.js +++ b/packages/form/addon/components/cf-content.js @@ -7,6 +7,7 @@ import { dropTask } from "ember-concurrency"; import { trackedTask } from "reactiveweb/ember-concurrency"; import getDocumentAnswersQuery from "@projectcaluma/ember-form/gql/queries/document-answers.graphql"; +import getDocumentAnswersCompareQuery from "@projectcaluma/ember-form/gql/queries/document-answers-compare.graphql"; import getDocumentFormsQuery from "@projectcaluma/ember-form/gql/queries/document-forms.graphql"; import { parseDocument } from "@projectcaluma/ember-form/lib/parsers"; @@ -134,14 +135,30 @@ export default class CfContentComponent extends Component { if (!this.args.documentId) return; - const [answerDocument] = (yield this.apollo.query( - { - query: getDocumentAnswersQuery, + let answerDocument; + let historicalDocument; + if (this.args.compareTo) { + const response = yield this.apollo.query({ + query: getDocumentAnswersCompareQuery, fetchPolicy: "network-only", - variables: { id: this.args.documentId }, - }, - "allDocuments.edges", - )).map(({ node }) => node); + variables: { + id: this.args.documentId, + from: this.args.compareTo, + to: new Date(), + }, + }); + answerDocument = response.toRevision; + historicalDocument = response.fromRevision; + } else { + [answerDocument] = (yield this.apollo.query( + { + query: getDocumentAnswersQuery, + fetchPolicy: "network-only", + variables: { id: this.args.documentId }, + }, + "allDocuments.edges", + )).map(({ node }) => node); + } const [form] = (yield this.apollo.query( { @@ -161,6 +178,9 @@ export default class CfContentComponent extends Component { const document = new Document({ raw, owner, + historicalDocument: historicalDocument + ? parseDocument({ ...historicalDocument, form }) + : null, dataSourceContext: this.args.context, }); const navigation = new Navigation({ document, owner }); diff --git a/packages/form/addon/components/cf-field/input/integer.hbs b/packages/form/addon/components/cf-field/input/integer.hbs index 859fe2742..09030eb0c 100644 --- a/packages/form/addon/components/cf-field/input/integer.hbs +++ b/packages/form/addon/components/cf-field/input/integer.hbs @@ -12,4 +12,9 @@ placeholder={{@field.question.raw.placeholder}} readonly={{@disabled}} {{on "input" this.input}} -/> \ No newline at end of file +/> + +{{log @field.answer}} +{{#if @field.answer.historicalValue}} + Geändert! War: {{@field.answer.historicalValue}} +{{/if}} \ No newline at end of file diff --git a/packages/form/addon/components/cf-field/input/number-separator.hbs b/packages/form/addon/components/cf-field/input/number-separator.hbs index 05359487b..17bef6145 100644 --- a/packages/form/addon/components/cf-field/input/number-separator.hbs +++ b/packages/form/addon/components/cf-field/input/number-separator.hbs @@ -9,4 +9,9 @@ placeholder={{@field.question.raw.placeholder}} readonly={{this.disabled}} {{on "input" this.input}} -/> \ No newline at end of file +/> + +{{log @field.answer}} +{{#if @field.answer.historicalValue}} + Geändert! War: {{@field.answer.historicalValue}} +{{/if}} \ No newline at end of file diff --git a/packages/form/addon/components/cf-field/input/text.hbs b/packages/form/addon/components/cf-field/input/text.hbs index 96c4d3643..6f16ec01f 100644 --- a/packages/form/addon/components/cf-field/input/text.hbs +++ b/packages/form/addon/components/cf-field/input/text.hbs @@ -11,4 +11,8 @@ minlength={{@field.question.raw.textMinLength}} maxlength={{@field.question.raw.textMaxLength}} {{on "input" this.input}} -/> \ No newline at end of file +/> + +{{#if @field.answer.historicalValue}} + Geändert! War: {{@field.answer.historicalValue}} +{{/if}} \ No newline at end of file diff --git a/packages/form/addon/lib/answer.js b/packages/form/addon/lib/answer.js index d61071749..306091884 100644 --- a/packages/form/addon/lib/answer.js +++ b/packages/form/addon/lib/answer.js @@ -33,7 +33,7 @@ class DedupedTrackedObject { * @class Answer */ export default class Answer extends Base { - constructor({ raw, field, ...args }) { + constructor({ raw, field, historical, ...args }) { assert("`field` must be passed as an argument", field); assert( @@ -42,9 +42,11 @@ export default class Answer extends Base { ); super({ raw, ...args }); + console.log("init Answer", raw, historical); this.field = field; this.raw = new DedupedTrackedObject(raw); + this.historical = historical ? new DedupedTrackedObject(historical) : null; this.pushIntoStore(); } @@ -106,7 +108,11 @@ export default class Answer extends Base { get _valueKey() { return ( this.raw.__typename && - camelize(this.raw.__typename.replace(/Answer$/, "Value")) + camelize( + this.raw.__typename + .replace("Historical", "") + .replace(/Answer$/, "Value"), + ) ); } @@ -145,6 +151,12 @@ export default class Answer extends Base { return value; } + @cached + get historicalValue() { + return this.historical?.[this._valueKey]; + // TODO: support tables + } + set value(value) { if (this._valueKey) { this.raw[this._valueKey] = [undefined, ""].includes(value) ? null : value; diff --git a/packages/form/addon/lib/document.js b/packages/form/addon/lib/document.js index eb4097959..758999e34 100644 --- a/packages/form/addon/lib/document.js +++ b/packages/form/addon/lib/document.js @@ -22,14 +22,23 @@ const sum = (nums) => nums.reduce((num, base) => base + num, 0); * @class Document */ export default class Document extends Base { - constructor({ raw, parentDocument, dataSourceContext, ...args }) { + constructor({ + raw, + parentDocument, + dataSourceContext, + historicalDocument, + ...args + }) { assert( "A graphql document `raw` must be passed", - raw?.__typename === "Document", + raw?.__typename.includes("Document"), ); + console.log("init Document", { raw, historicalDocument }); + super({ raw, ...args }); + this.historicalDocument = historicalDocument; this.parentDocument = parentDocument; this.dataSourceContext = dataSourceContext ?? parentDocument?.dataSourceContext; @@ -59,7 +68,11 @@ export default class Document extends Base { this, this.calumaStore.find(`${this.pk}:Form:${form.slug}`) || new (owner.factoryFor("caluma-model:fieldset").class)({ - raw: { form, answers: this.raw.answers }, + raw: { + form, + answers: this.raw.answers, + historicalAnswers: this.historicalDocument?.answers, + }, document: this, owner, }), diff --git a/packages/form/addon/lib/field.js b/packages/form/addon/lib/field.js index f4111c964..9fa3cebd6 100644 --- a/packages/form/addon/lib/field.js +++ b/packages/form/addon/lib/field.js @@ -72,6 +72,7 @@ export default class Field extends Base { assert("`fieldset` must be passed as an argument", fieldset); super({ fieldset, ...args }); + console.log("init field", args); this.fieldset = fieldset; @@ -121,7 +122,12 @@ export default class Field extends Base { } else { answer = this.calumaStore.find(`Answer:${decodeId(this.raw.answer.id)}`) || - new Answer({ raw: this.raw.answer, field: this, owner }); + new Answer({ + raw: this.raw.answer, + historical: this.raw.historicalAnswer, + field: this, + owner, + }); } this.answer = associateDestroyableChild(this, answer); diff --git a/packages/form/addon/lib/fieldset.js b/packages/form/addon/lib/fieldset.js index 749edb499..14259d4eb 100644 --- a/packages/form/addon/lib/fieldset.js +++ b/packages/form/addon/lib/fieldset.js @@ -22,6 +22,7 @@ export default class Fieldset extends Base { "A collection of graphql answers `raw.answers` must be passed", raw?.answers?.every((answer) => /Answer$/.test(answer.__typename)), ); + console.log("init fieldset", raw); super({ raw, ...args }); @@ -61,6 +62,9 @@ export default class Fieldset extends Base { answer: this.raw.answers.find( (answer) => answer?.question?.slug === question.slug, ), + historicalAnswer: this.raw.historicalAnswers?.find( + (answer) => answer?.question?.slug === question.slug, + ), }, fieldset: this, owner, diff --git a/packages/form/addon/lib/parsers.js b/packages/form/addon/lib/parsers.js index dc49fd44e..5f25ccac4 100644 --- a/packages/form/addon/lib/parsers.js +++ b/packages/form/addon/lib/parsers.js @@ -3,15 +3,20 @@ import { assert } from "@ember/debug"; export const parseDocument = (response) => { assert( "The passed document must be a GraphQL document", - response.__typename === "Document", + response.__typename.includes("Document"), ); assert("The passed document must include a form", response.form); - assert("The passed document must include answers", response.answers); + assert( + "The passed document must include answers", + response.answers ?? response.historicalAnswers, + ); return { ...response, rootForm: parseForm(response.form), - answers: response.answers.edges.map(({ node }) => parseAnswer(node)), + answers: (response.answers ?? response.historicalAnswers).edges.map( + ({ node }) => parseAnswer(node), + ), forms: parseFormTree(response.form), }; }; diff --git a/packages/testing/addon/mirage-graphql/schema.graphql b/packages/testing/addon/mirage-graphql/schema.graphql index 09938c799..6150668c3 100644 --- a/packages/testing/addon/mirage-graphql/schema.graphql +++ b/packages/testing/addon/mirage-graphql/schema.graphql @@ -216,6 +216,7 @@ type AnalyticsTable implements Node { meta: JSONString! disableVisibilities: Boolean! name: String! + description: String startingObject: StartingObject fields( offset: Int @@ -298,6 +299,7 @@ type AnalyticsTableEdge { input AnalyticsTableFilterSetType { slug: String name: String + description: String createdByUser: String createdByGroup: String modifiedByUser: String @@ -514,6 +516,86 @@ type CalculatedFloatQuestion implements Question & Node { id: ID! } +""" +An enumeration. +""" +enum CalumaFormHistoricalAnswerHistoryQuestionTypeChoices { + """ + multiple_choice + """ + MULTIPLE_CHOICE + + """ + integer + """ + INTEGER + + """ + float + """ + FLOAT + + """ + date + """ + DATE + + """ + choice + """ + CHOICE + + """ + textarea + """ + TEXTAREA + + """ + text + """ + TEXT + + """ + table + """ + TABLE + + """ + form + """ + FORM + + """ + files + """ + FILES + + """ + dynamic_choice + """ + DYNAMIC_CHOICE + + """ + dynamic_multiple_choice + """ + DYNAMIC_MULTIPLE_CHOICE + + """ + static + """ + STATIC + + """ + calculated_float + """ + CALCULATED_FLOAT + + """ + action_button + """ + ACTION_BUTTON +} + input CancelCaseInput { id: ID! @@ -633,7 +715,6 @@ input CaseFilterSetType { modifiedAfter: DateTime metaHasKey: String metaValue: [JSONValueFilterType] - id: ID ids: [ID] documentForm: String documentForms: [String] @@ -1032,6 +1113,31 @@ type DjangoDebug { Executed SQL queries for this API query. """ sql: [DjangoDebugSQL] + + """ + Raise exceptions for this API query. + """ + exceptions: [DjangoDebugException] +} + +""" +Represents a single exception raised. +""" +type DjangoDebugException { + """ + The class of the exception + """ + excType: String! + + """ + The message of the exception + """ + message: String! + + """ + The stack trace + """ + stack: String! } """ @@ -1812,6 +1918,265 @@ input HasAnswerFilterType { hierarchy: AnswerHierarchyMode } +interface HistoricalAnswer { + id: ID + createdAt: DateTime! + modifiedAt: DateTime! + createdByUser: String + createdByGroup: String + modifiedByUser: String + modifiedByGroup: String + question: Question! + meta: GenericScalar! + historyDate: DateTime! + historyUserId: String + historyType: String +} + +type HistoricalAnswerConnection { + """ + Pagination data for this connection. + """ + pageInfo: PageInfo! + + """ + Contains the nodes in this connection. + """ + edges: [HistoricalAnswerEdge]! + totalCount: Int +} + +""" +A Relay edge containing a `HistoricalAnswer` and its cursor. +""" +type HistoricalAnswerEdge { + """ + The item at the end of the edge + """ + node: HistoricalAnswer + + """ + A cursor for use in pagination + """ + cursor: String! +} + +type HistoricalDateAnswer implements HistoricalAnswer & Node { + createdAt: DateTime! + modifiedAt: DateTime! + createdByUser: String + createdByGroup: String + modifiedByUser: String + modifiedByGroup: String + historyQuestionType: CalumaFormHistoricalAnswerHistoryQuestionTypeChoices! + + """ + The ID of the object + """ + id: ID! + value: Date + meta: GenericScalar! + historyUserId: String + question: Question! + historyId: UUID! + historyDate: DateTime! + historyChangeReason: String + historyType: String + date: Date +} + +type HistoricalDocument implements Node { + historyUserId: String + createdAt: DateTime! + modifiedAt: DateTime! + createdByUser: String + createdByGroup: String + modifiedByUser: String + modifiedByGroup: String + + """ + The ID of the object + """ + id: ID! + meta: GenericScalar + form: Form + + """ + Reference this document has been copied from + """ + source: Document + historyDate: DateTime! + historyType: String + historicalAnswers( + asOf: DateTime! + before: String + after: String + first: Int + last: Int + ): HistoricalAnswerConnection + documentId: UUID +} + +type HistoricalFile implements Node { + """ + The ID of the object + """ + id: ID! + name: String! + downloadUrl: String + metadata: GenericScalar + historicalAnswer: HistoricalFilesAnswer + historyDate: DateTime! + historyUserId: String + historyType: String +} + +type HistoricalFilesAnswer implements HistoricalAnswer & Node { + createdAt: DateTime! + modifiedAt: DateTime! + createdByUser: String + createdByGroup: String + modifiedByUser: String + modifiedByGroup: String + historyQuestionType: CalumaFormHistoricalAnswerHistoryQuestionTypeChoices! + + """ + The ID of the object + """ + id: ID! + value(asOf: DateTime!): [HistoricalFile] + meta: GenericScalar! + historyUserId: String + question: Question! + historyId: UUID! + historyDate: DateTime! + historyChangeReason: String + historyType: String +} + +type HistoricalFloatAnswer implements HistoricalAnswer & Node { + createdAt: DateTime! + modifiedAt: DateTime! + createdByUser: String + createdByGroup: String + modifiedByUser: String + modifiedByGroup: String + historyQuestionType: CalumaFormHistoricalAnswerHistoryQuestionTypeChoices! + + """ + The ID of the object + """ + id: ID! + value: Float + meta: GenericScalar! + historyUserId: String + question: Question! + historyId: UUID! + historyDate: DateTime! + historyChangeReason: String + historyType: String +} + +type HistoricalIntegerAnswer implements HistoricalAnswer & Node { + createdAt: DateTime! + modifiedAt: DateTime! + createdByUser: String + createdByGroup: String + modifiedByUser: String + modifiedByGroup: String + historyQuestionType: CalumaFormHistoricalAnswerHistoryQuestionTypeChoices! + + """ + The ID of the object + """ + id: ID! + value: Int + meta: GenericScalar! + historyUserId: String + question: Question! + historyId: UUID! + historyDate: DateTime! + historyChangeReason: String + historyType: String +} + +type HistoricalListAnswer implements HistoricalAnswer & Node { + createdAt: DateTime! + modifiedAt: DateTime! + createdByUser: String + createdByGroup: String + modifiedByUser: String + modifiedByGroup: String + historyQuestionType: CalumaFormHistoricalAnswerHistoryQuestionTypeChoices! + + """ + The ID of the object + """ + id: ID! + value: [String] + meta: GenericScalar! + historyUserId: String + question: Question! + historyId: UUID! + historyDate: DateTime! + historyChangeReason: String + historyType: String + selectedOptions( + before: String + after: String + first: Int + last: Int + ): SelectedOptionConnection +} + +type HistoricalStringAnswer implements HistoricalAnswer & Node { + createdAt: DateTime! + modifiedAt: DateTime! + createdByUser: String + createdByGroup: String + modifiedByUser: String + modifiedByGroup: String + historyQuestionType: CalumaFormHistoricalAnswerHistoryQuestionTypeChoices! + + """ + The ID of the object + """ + id: ID! + value: String + meta: GenericScalar! + historyUserId: String + question: Question! + historyId: UUID! + historyDate: DateTime! + historyChangeReason: String + historyType: String + selectedOption: SelectedOption +} + +type HistoricalTableAnswer implements HistoricalAnswer & Node { + createdAt: DateTime! + modifiedAt: DateTime! + createdByUser: String + createdByGroup: String + modifiedByUser: String + modifiedByGroup: String + historyQuestionType: CalumaFormHistoricalAnswerHistoryQuestionTypeChoices! + + """ + The ID of the object + """ + id: ID! + value(asOf: DateTime!): [HistoricalDocument] + meta: GenericScalar! + historyUserId: String + question: Question! + historyId: UUID! + historyDate: DateTime! + historyChangeReason: String + historyType: String + document: Document +} + type IntegerAnswer implements Answer & Node { createdAt: DateTime! modifiedAt: DateTime! @@ -2203,6 +2568,7 @@ type PageInfo { } type Query { + documentAsOf(id: ID!, asOf: DateTime!): HistoricalDocument allAnalyticsTables( offset: Int before: String @@ -2668,6 +3034,7 @@ type SaveAnalyticsFieldPayload { input SaveAnalyticsTableInput { slug: String! name: String! + description: String startingObject: StartingObject! disableVisibilities: Boolean meta: JSONString @@ -3406,7 +3773,7 @@ Lookup type to search in answers. You may pass in a list of question slugs and/or a list of form slugs to define which answers to search. If you pass in one or more forms, answers to the questions in that form will be searched. If you pass in one or more question -slug, the corresponding answers are searched. If you pass both, a superset +slugs, the corresponding answers are searched. If you pass both, a superset of both is searched (ie. they do not limit each other). """ input SearchAnswersFilterType { @@ -3416,10 +3783,17 @@ input SearchAnswersFilterType { lookup: SearchLookupMode } +""" +Lookup used in SearchAnswersFilterType. + +Keep in mind that the SearchAnswer filter operates on a word-by-word basis. +This defines the lookup used for every single word. +""" enum SearchLookupMode { STARTSWITH CONTAINS TEXT + EXACT_WORD } type SelectedOption { @@ -3509,6 +3883,7 @@ enum SortableAnalyticsTableAttributes { MODIFIED_AT SLUG NAME + DESCRIPTION } enum SortableAnswerAttributes { @@ -3986,6 +4361,12 @@ enum Type { COMPLETE_TASK_FORM } +""" +Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects +in fields, resolvers and input. +""" +scalar UUID + type ValidationEntry { slug: String! errorMsg: String!